diff --git a/core-web/apps/dotcms-ui-e2e/playwright.config.ts b/core-web/apps/dotcms-ui-e2e/playwright.config.ts index d8e9b2e3654e..c08f0d593267 100644 --- a/core-web/apps/dotcms-ui-e2e/playwright.config.ts +++ b/core-web/apps/dotcms-ui-e2e/playwright.config.ts @@ -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 */ @@ -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: diff --git a/core-web/apps/dotcms-ui-e2e/pom.xml b/core-web/apps/dotcms-ui-e2e/pom.xml index 0a3def0543f0..0ce9973e9a67 100644 --- a/core-web/apps/dotcms-ui-e2e/pom.xml +++ b/core-web/apps/dotcms-ui-e2e/pom.xml @@ -24,6 +24,18 @@ local ../../ + + --max-old-space-size=4096 nx run dotcms-ui-e2e:e2e --configuration=${e2e.test.env} -- ${e2e.playwright.args} exec sh -c "mkdir -p apps/dotcms-ui-e2e/target/playwright-reports && cp apps/dotcms-ui-e2e/test-results/junit.xml apps/dotcms-ui-e2e/target/playwright-reports/junit.xml" 8080 @@ -90,6 +102,7 @@ true ${e2e.test.env} + ${e2e.node.options} ${node.install.dir}:${env.PATH} diff --git a/core-web/apps/dotcms-ui-e2e/src/components/asset-picker-dialog.component.ts b/core-web/apps/dotcms-ui-e2e/src/components/asset-picker-dialog.component.ts new file mode 100644 index 000000000000..3ec15cdbd24f --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/components/asset-picker-dialog.component.ts @@ -0,0 +1,120 @@ +import { type Locator, type Page, expect } from '@playwright/test'; + +/** + * Locator helper for the AssetPicker dialog — the one "browse for an existing asset" modal in the + * product. Four entry points open it, which is why this lives in `components/` rather than under any + * one field's `helpers/`: + * + * - the File field's "Select Existing File" + * - the Image field's "Select Existing Image" + * - the Story Block's insert image / video / audio toolbar buttons and slash commands + * - the WYSIWYG (TinyMCE) field's insert-image button + * + * 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'); + // Title and close come from the shared dialog shell, so their ids are not picker-specific. + this.title = this.root.getByTestId('dialog-title'); + this.closeButton = this.root.getByTestId('dialog-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 { + await expect(this.root).toBeVisible({ timeout: 15000 }); + } + + async expectClosed(): Promise { + await expect(this.root).toBeHidden({ timeout: 10000 }); + } + + async expectTitle(text: string): Promise { + 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 { + 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 { + 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 { + await this.row(name).getByTestId('item-title-text').click(); + } + + async expectRowSelected(name: string): Promise { + await expect(this.row(name).getByRole('radio')).toBeChecked(); + } + + async expectConfirmEnabled(): Promise { + await expect(this.confirmButton.getByRole('button')).toBeEnabled(); + } + + async expectConfirmDisabled(): Promise { + await expect(this.confirmButton.getByRole('button')).toBeDisabled(); + } + + async confirm(): Promise { + await this.confirmButton.getByRole('button').click(); + } + + async cancel(): Promise { + await this.cancelButton.getByRole('button').click(); + } + + async close(): Promise { + await this.closeButton.getByRole('button').click(); + } + + /** Rows offer no per-row actions here — a row exists to be picked, not managed. */ + async expectNoRowActions(): Promise { + await expect(this.list.getByTestId('kebab-menu-button')).toHaveCount(0); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts b/core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts index 3bb529c8d9f1..ab1a53ea4bfb 100644 --- a/core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts +++ b/core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts @@ -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 { + 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. @@ -79,6 +121,11 @@ export async function relateContent( /** * Deletes contentlets by their identifiers. * + * Fires DESTROY through the WORKFLOW resource. `/api/v1/content/actions/...` — which this used to + * call — does not exist and answered 404 for every contentlet, so nothing was ever deleted and the + * 404 was swallowed as "already gone". Every suite using this leaked its seeded content into the + * environment on each run. + * * @param request - Playwright APIRequestContext * @param identifiers - Array of contentlet identifiers to delete */ @@ -87,7 +134,7 @@ export async function deleteContentlets( identifiers: string[] ): Promise { for (const identifier of identifiers) { - const endpoint = `/api/v1/content/actions/default/fire/DESTROY?identifier=${identifier}`; + const endpoint = `/api/v1/workflow/actions/default/fire/DESTROY?identifier=${identifier}`; const response = await request.put(endpoint, { headers: { Authorization: generateBase64Credentials(admin1.username, admin1.password) diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/block-editor-field-asset-picker.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/block-editor-field-asset-picker.spec.ts new file mode 100644 index 000000000000..12563bfa182c --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/block-editor-field-asset-picker.spec.ts @@ -0,0 +1,208 @@ +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 { + createFakePayloadBlockEditorField, + createFakePayloadTextField +} from '@utils/dot-content-types.mock'; +import { uniqueSuffix } from '@utils/utils'; + +import { AssetPickerDialog } from '@components/asset-picker-dialog.component'; + +import { BlockEditorField } from './helpers/block-editor-field'; + +import { + createTestPngFile, + createTestTextFile +} from '../file-upload-fields/helpers/file-test-data'; + +const BLOCK_EDITOR_FIELD_VARIABLE = 'blockEditorField'; + +let contentType: ContentType | null = null; +let contentTypeVariable: string; + +let seededImage: Contentlet | null = null; +let seededTextFile: Contentlet | null = null; + +test.beforeEach(async ({ request }) => { + contentType = await createFakeContentType(request, { + name: `E2EBlockEditorField${uniqueSuffix()}`, + fields: [ + createFakePayloadTextField({ name: 'Title', variable: 'title', sortOrder: 1 }), + createFakePayloadBlockEditorField({ + name: 'Block Editor Field', + variable: BLOCK_EDITOR_FIELD_VARIABLE, + sortOrder: 2 + }) + ] + }); + contentTypeVariable = contentType.variable; + + // Two assets on purpose: the image is what the picker must offer, the text file is what its + // mimetype restriction must hide. Seeded immediately before the test and named uniquely, so they + // are the newest rows in the picker's default `modDate:desc` listing. + const site = await getDefaultSite(request); + const suffix = uniqueSuffix(); + + seededImage = await createDotAsset( + request, + createTestPngFile(`e2e-block-editor-${suffix}.png`), + site.identifier + ); + seededTextFile = await createDotAsset( + request, + createTestTextFile(`e2e-block-editor-${suffix}.txt`), + site.identifier + ); +}); + +test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + + const identifiers = [seededImage, seededTextFile] + .filter((asset): asset is Contentlet => !!asset) + .map((asset) => asset.identifier); + + if (identifiers.length) { + await deleteContentlets(request, identifiers); + } + + seededImage = null; + seededTextFile = null; +}); + +test.describe('Block Editor — insert an image through the AssetPicker', () => { + test('open the picker from the toolbar, select an image, and embed it @critical', async ({ + page + }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BlockEditorField(page, BLOCK_EDITOR_FIELD_VARIABLE); + await field.expectVisible(); + await field.expectNoImages(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + await picker.expectConfirmDisabled(); + + await picker.expectRowVisible(image.title); + + await picker.selectRowByTitle(image.title); + await picker.expectRowSelected(image.title); + await picker.expectConfirmEnabled(); + + await picker.confirm(); + + await picker.expectClosed(); + await field.expectImageInserted(image.inode, image.title); + }); + + test('the picker the Story Block opens is the same one the File field opens', async ({ + page + }) => { + // The point of the unification: one picker, with its folder tree and its own header, rather + // than the older browser-selector this used to open. + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BlockEditorField(page, BLOCK_EDITOR_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + await picker.expectTitle('Add Image'); + await expect(picker.sidebar).toBeVisible(); + await expect(picker.treeSearch).toBeVisible(); + // A row here exists to be picked, not managed. + await picker.expectNoRowActions(); + }); + + test('picker for an image block offers images but not other files', async ({ page }) => { + // The mimetype restriction is applied silently and cannot be cleared from the UI — a + // `dotImage` node pointing at a .txt is broken. + const image = seededImage as Contentlet; + const textFile = seededTextFile as Contentlet; + + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BlockEditorField(page, BLOCK_EDITOR_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + // Both assets are the newest in the environment, so the picker's own listing is where + // this shows: one of them is offered and the other never appears. + await picker.expectRowVisible(image.title); + await expect(picker.row(textFile.title)).toHaveCount(0); + }); + + test('cancelling the picker embeds nothing', async ({ page }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BlockEditorField(page, BLOCK_EDITOR_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + // Select first: cancelling after a selection is the case that would leak a node if the + // dialog reported a result on dismiss. + await picker.expectRowVisible(image.title); + await picker.selectRowByTitle(image.title); + await picker.cancel(); + + await picker.expectClosed(); + await field.expectNoImages(); + }); + + test('the embedded image survives a save and reload @critical', async ({ page }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BlockEditorField(page, BLOCK_EDITOR_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + await picker.expectRowVisible(image.title); + await picker.selectRowByTitle(image.title); + await picker.confirm(); + await picker.expectClosed(); + await field.expectImageInserted(image.inode, image.title); + + await formPage.fillTextField(`E2E Block Editor ${uniqueSuffix()}`); + await formPage.save(); + + await page.waitForURL(/\/content\/([a-f0-9-]+)/); + const [, savedIdentifier] = page.url().match(/\/content\/([a-f0-9-]+)/) as RegExpMatchArray; + expect(savedIdentifier).toBeTruthy(); + + await page.goto(`/dotAdmin/#/content/${savedIdentifier}`); + await page.waitForLoadState('domcontentloaded'); + await page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 }); + + // What this really covers is the node's stored `data` payload: the picker hands back a + // hydrated contentlet, and the node keeps only identifier/inode/languageId/title/asset from + // it. If any of those went missing the reloaded document would render a broken image. + await field.expectVisible(); + await field.expectImageInserted(image.inode, image.title); + }); +}); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/helpers/block-editor-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/helpers/block-editor-field.ts new file mode 100644 index 000000000000..72006422ed87 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/block-editor-field/helpers/block-editor-field.ts @@ -0,0 +1,102 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * Locator wrapper for the Block Editor (Story Block) field — `dot-edit-content-block-editor` + * rendering `dot-block-editor` from `libs/new-block-editor`. + * + * Scopes everything to `data-testid="field-{variable}"`. The editing surface is the toolbar's + * sibling, exposed as `role="textbox"` with `aria-multiline`, so it is reachable by role rather than + * by the ProseMirror class. + */ +export class BlockEditorField { + readonly root: Locator; + readonly editor: Locator; + readonly content: Locator; + readonly insertImageButton: Locator; + readonly insertVideoButton: Locator; + readonly insertAudioButton: Locator; + + constructor( + private page: Page, + readonly fieldVariable = 'blockEditorField' + ) { + this.root = page.getByTestId(`field-${fieldVariable}`); + this.editor = this.root.locator('dot-block-editor'); + // `.first()` because there are two nested textboxes: the labelled wrapper the editor renders + // and the `contenteditable` ProseMirror puts inside it. The wrapper is the outer of the two, + // so descendant queries from here still reach the document's content. + this.content = this.root.getByRole('textbox').first(); + // `exact` matters: "Edit image properties" and "Insert asset by URL" also live in this + // toolbar, and a substring match would make these ambiguous the moment a label changes. + this.insertImageButton = this.root.getByRole('button', { + name: 'Insert image', + exact: true + }); + this.insertVideoButton = this.root.getByRole('button', { + name: 'Insert video', + exact: true + }); + this.insertAudioButton = this.root.getByRole('button', { + name: 'Insert audio', + exact: true + }); + } + + /** + * Waits for the NEW block editor to be the one on screen. + * + * `FEATURE_FLAG_NEW_BLOCK_EDITOR` resolves to `true` when unset, so this is the default — but if + * an environment has it explicitly `false` the legacy `dot-old-block-editor` renders instead and + * every locator here silently finds nothing. Failing on this element gives that a name. + */ + async expectVisible(): Promise { + await expect(this.editor).toBeVisible({ timeout: 20000 }); + await expect(this.content).toBeVisible({ timeout: 15000 }); + } + + /** + * Opens the shared AssetPicker from a toolbar button and waits for its first result page. + * + * Two requests happen between the click and a usable dialog: the picker needs the current site + * before it can be configured, and only then does it search. Waiting on the search covers both — + * it cannot fire until the dialog has mounted. + */ + async openAssetPicker(button: Locator): Promise { + const searchResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/drive/search') && response.status() === 200, + { timeout: 30000 } + ); + + await button.click(); + await searchResponse; + } + + async openImagePicker(): Promise { + await this.openAssetPicker(this.insertImageButton); + } + + /** The images embedded in the document. */ + get images(): Locator { + return this.content.getByRole('img'); + } + + /** + * Asserts an image node was inserted for exactly this asset. + * + * Keys on the inode rather than the file name: `insertDotImageFromContentlet` builds the src as + * `/dA/{inode}`, so this is what proves the picked row is the one that landed in the document + * and not some other asset with a similar title. + */ + async expectImageInserted(inode: string, title: string): Promise { + const image = this.images.first(); + + await expect(image).toBeVisible({ timeout: 15000 }); + await expect(image).toHaveAttribute('src', new RegExp(inode)); + await expect(image).toHaveAttribute('alt', title); + } + + async expectNoImages(): Promise { + await expect(this.images).toHaveCount(0); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts index a94f2fa9d38e..bded48eac011 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts @@ -1,16 +1,20 @@ 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 } from '@utils/dot-content-types.mock'; import { uniqueSuffix } from '@utils/utils'; +import { AssetPickerDialog } from '@components/asset-picker-dialog.component'; + import { FileField } from './helpers/file-field'; -import { E2E_IMPORT_URL, createTestTextFile } from '../helpers/file-test-data'; +import { E2E_IMPORT_URL, createTestPngFile, createTestTextFile } from '../helpers/file-test-data'; const FILE_FIELD_VARIABLE = 'fileField'; const TEST_FILE = createTestTextFile(); @@ -118,6 +122,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; diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts index 34e5fd7e99b2..1d2d7d768a50 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts @@ -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 }); } diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts index 71f6104a39e2..7a6f62cd3d58 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts @@ -1,16 +1,20 @@ 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 { createFakePayloadImageField, createFakePayloadTextField } from '@utils/dot-content-types.mock'; import { uniqueSuffix } from '@utils/utils'; +import { AssetPickerDialog } from '@components/asset-picker-dialog.component'; + import { ImageField } from './helpers/image-field'; -import { createTestPngFile } from '../helpers/file-test-data'; +import { createTestPngFile, createTestTextFile } from '../helpers/file-test-data'; const IMAGE_FIELD_VARIABLE = 'imageField'; const TEST_IMAGE = createTestPngFile(); @@ -120,6 +124,96 @@ test.describe('required image field', () => { }); }); +test.describe('select an existing image through the AssetPicker', () => { + let seededImage: Contentlet | null = null; + let seededTextFile: Contentlet | null = null; + let imageName: string; + let textFileName: string; + + // Two assets on purpose: the image is what the field can take, the text file is what it must + // refuse to offer. Both seeded through the REST API with unique names so the picker's search + // reaches exactly these regardless of what else lives in the environment. + test.beforeEach(async ({ request }) => { + const site = await getDefaultSite(request); + const suffix = uniqueSuffix(); + + seededImage = await createDotAsset( + request, + createTestPngFile(`e2e-picker-${suffix}.png`), + site.identifier + ); + seededTextFile = await createDotAsset( + request, + createTestTextFile(`e2e-picker-${suffix}.txt`), + site.identifier + ); + + imageName = seededImage.title; + textFileName = seededTextFile.title; + }); + + test.afterEach(async ({ request }) => { + const identifiers = [seededImage, seededTextFile] + .filter((asset): asset is Contentlet => !!asset) + .map((asset) => asset.identifier); + + if (identifiers.length) { + await deleteContentlets(request, identifiers); + } + + seededImage = null; + seededTextFile = null; + }); + + test('open the picker, select an image, and populate the field @critical', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new ImageField(page, IMAGE_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openSelectExistingDialog(); + await picker.waitForVisible(); + await picker.expectConfirmDisabled(); + + await picker.searchFor(imageName); + await picker.expectRowVisible(imageName); + + await picker.selectRowByTitle(imageName); + await picker.expectRowSelected(imageName); + + await picker.confirm(); + + await picker.expectClosed(); + await field.expectPreviewVisible(); + await field.expectThumbnailVisible(); + await field.expectPreviewShowsFileName(imageName); + }); + + test('picker for an image field offers images but not other files', async ({ page }) => { + // The mimetype restriction is applied silently and cannot be cleared from the UI — an Image + // field that could return a .txt is broken. + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new ImageField(page, IMAGE_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openSelectExistingDialog(); + await picker.waitForVisible(); + + // Both assets share the suffix, so one search surfaces whichever the picker is willing + // to offer. + const sharedTerm = imageName.replace(/\.png$/, ''); + await picker.searchFor(sharedTerm); + + await picker.expectRowVisible(imageName); + await expect(picker.row(textFileName)).toHaveCount(0); + }); +}); + test('image field shows Generate With dotAI and hides Create New File @smoke', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/helpers/wysiwyg-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/helpers/wysiwyg-field.ts new file mode 100644 index 000000000000..5b735d0f6332 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/helpers/wysiwyg-field.ts @@ -0,0 +1,95 @@ +import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test'; + +/** + * Locator wrapper for the WYSIWYG field (`dot-edit-content-wysiwyg-field`) on its default TinyMCE + * editor. + * + * TinyMCE splits itself across the iframe boundary: the toolbar is regular DOM inside the field, the + * document being edited is a separate iframe. Anything asserting on inserted content has to go + * through {@link body}. + */ +export class WysiwygField { + readonly root: Locator; + readonly editorSelector: Locator; + readonly toolbar: Locator; + readonly insertImageButton: Locator; + + constructor( + private page: Page, + readonly fieldVariable = 'wysiwygField' + ) { + this.root = page.getByTestId(`field-${fieldVariable}`); + this.editorSelector = this.root.getByTestId('editor-selector'); + this.toolbar = this.root.getByRole('toolbar'); + // The button is icon-only; its accessible name comes from the `tooltip` the plugin registers + // (TinyMCE's silver theme renders `tooltip` as both `title` and `aria-label`). + this.insertImageButton = this.root.getByRole('button', { + name: 'Insert Image', + exact: true + }); + } + + /** The document being edited, which TinyMCE keeps in its own iframe. */ + get body(): FrameLocator { + return this.root.frameLocator('iframe'); + } + + /** + * Waits for TinyMCE to finish booting. + * + * The script is loaded on demand, so the field renders its wrapper well before the toolbar + * exists. Waiting on the button this suite clicks is the tightest signal available. + */ + async expectVisible(): Promise { + await expect(this.insertImageButton).toBeVisible({ timeout: 30000 }); + } + + /** + * Opens the shared AssetPicker from the toolbar and waits for its first result page. + * + * Two requests happen between the click and a usable dialog: the picker needs the current site + * before it can be configured, and only then does it search. Waiting on the search covers both — + * it cannot fire until the dialog has mounted. + */ + async openImagePicker(): Promise { + const searchResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/drive/search') && response.status() === 200, + { timeout: 30000 } + ); + + await this.insertImageButton.click(); + await searchResponse; + } + + /** The images embedded in the document. */ + get images(): Locator { + return this.body.getByRole('img'); + } + + /** + * Asserts an `` was inserted for exactly this asset. + * + * Keys on `data-identifier` rather than the src: the src is built from a configurable pattern + * (`WYSIWYG_IMAGE_URL_PATTERN`), so an environment that customises it would break a src + * assertion while the behaviour under test is still correct. The data attributes are written + * unconditionally by `formatDotImageNode`. + */ + async expectImageInserted(identifier: string, inode: string, title: string): Promise { + const image = this.images.first(); + + await expect(image).toBeVisible({ timeout: 15000 }); + await expect(image).toHaveAttribute('data-identifier', identifier); + await expect(image).toHaveAttribute('data-inode', inode); + await expect(image).toHaveAttribute('alt', title); + } + + async expectNoImages(): Promise { + await expect(this.images).toHaveCount(0); + } + + /** Whether the toolbar button carries an accessible name — it is icon-only. */ + async expectInsertImageButtonLabelled(): Promise { + await expect(this.insertImageButton).toHaveAttribute('aria-label', 'Insert Image'); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/wysiwyg-field-asset-picker.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/wysiwyg-field-asset-picker.spec.ts new file mode 100644 index 000000000000..b78d71a609c6 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/wysiwyg-field/wysiwyg-field-asset-picker.spec.ts @@ -0,0 +1,221 @@ +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 { + createFakePayloadTextField, + createFakePayloadWYSIWYGField +} from '@utils/dot-content-types.mock'; +import { uniqueSuffix } from '@utils/utils'; + +import { AssetPickerDialog } from '@components/asset-picker-dialog.component'; + +import { WysiwygField } from './helpers/wysiwyg-field'; + +import { + createTestPngFile, + createTestTextFile +} from '../file-upload-fields/helpers/file-test-data'; + +const WYSIWYG_FIELD_VARIABLE = 'wysiwygField'; + +let contentType: ContentType | null = null; +let contentTypeVariable: string; + +let seededImage: Contentlet | null = null; +let seededTextFile: Contentlet | null = null; + +test.beforeEach(async ({ request }) => { + contentType = await createFakeContentType(request, { + name: `E2EWysiwygField${uniqueSuffix()}`, + fields: [ + createFakePayloadTextField({ name: 'Title', variable: 'title', sortOrder: 1 }), + createFakePayloadWYSIWYGField({ + name: 'WYSIWYG Field', + variable: WYSIWYG_FIELD_VARIABLE, + sortOrder: 2 + }) + ] + }); + contentTypeVariable = contentType.variable; + + // Two assets on purpose: the image is what the picker must offer, the text file is what its + // mimetype restriction must hide. Seeded immediately before the test and named uniquely, so they + // are the newest rows in the picker's default `modDate:desc` listing. + const site = await getDefaultSite(request); + const suffix = uniqueSuffix(); + + seededImage = await createDotAsset( + request, + createTestPngFile(`e2e-wysiwyg-${suffix}.png`), + site.identifier + ); + seededTextFile = await createDotAsset( + request, + createTestTextFile(`e2e-wysiwyg-${suffix}.txt`), + site.identifier + ); +}); + +test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + + const identifiers = [seededImage, seededTextFile] + .filter((asset): asset is Contentlet => !!asset) + .map((asset) => asset.identifier); + + if (identifiers.length) { + await deleteContentlets(request, identifiers); + } + + seededImage = null; + seededTextFile = null; +}); + +test.describe('WYSIWYG — insert an image through the AssetPicker', () => { + test('open the picker from the toolbar, select an image, and insert it @critical', async ({ + page + }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + await field.expectNoImages(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + await picker.expectConfirmDisabled(); + + await picker.expectRowVisible(image.title); + + await picker.selectRowByTitle(image.title); + await picker.expectRowSelected(image.title); + await picker.expectConfirmEnabled(); + + await picker.confirm(); + + await picker.expectClosed(); + // The insert goes through `formatDotImageNode`, which reads far more of the contentlet than + // the Story Block does — path, extension, hostName and the shorty ids. This is the assertion + // that proves the hydrated contentlet the picker returns carries all of it. + await field.expectImageInserted(image.identifier, image.inode, image.title); + }); + + test('the picker the WYSIWYG opens is the same one the File field opens', async ({ page }) => { + // The point of the unification: one picker, with its folder tree and its own header, rather + // than the older grid search dialog this used to open. + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + await picker.expectTitle('Add Image'); + await expect(picker.sidebar).toBeVisible(); + await expect(picker.treeSearch).toBeVisible(); + await picker.expectNoRowActions(); + }); + + test('picker for the WYSIWYG offers images but not other files', async ({ page }) => { + const image = seededImage as Contentlet; + const textFile = seededTextFile as Contentlet; + + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + // Both assets are the newest in the environment, so the picker's own listing is where this + // shows: one of them is offered and the other never appears. + await picker.expectRowVisible(image.title); + await expect(picker.row(textFile.title)).toHaveCount(0); + }); + + test('cancelling the picker inserts nothing and returns focus to the editor', async ({ + page + }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + + // Select first: cancelling after a selection is the case that would leak an if the + // dialog reported a result on dismiss. + await picker.expectRowVisible(image.title); + await picker.selectRowByTitle(image.title); + await picker.cancel(); + + await picker.expectClosed(); + await field.expectNoImages(); + + // The plugin refocuses the editor on every close, insert or dismiss, so the user is never + // left with nothing focused. + await expect(field.body.locator('body')).toBeFocused(); + }); + + test('the inserted image survives a save and reload @critical', async ({ page }) => { + const image = seededImage as Contentlet; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openImagePicker(); + await picker.waitForVisible(); + await picker.expectRowVisible(image.title); + await picker.selectRowByTitle(image.title); + await picker.confirm(); + await picker.expectClosed(); + await field.expectImageInserted(image.identifier, image.inode, image.title); + + await formPage.fillTextField(`E2E WYSIWYG ${uniqueSuffix()}`); + await formPage.save(); + + await page.waitForURL(/\/content\/([a-f0-9-]+)/); + const [, savedIdentifier] = page.url().match(/\/content\/([a-f0-9-]+)/) as RegExpMatchArray; + expect(savedIdentifier).toBeTruthy(); + + await page.goto(`/dotAdmin/#/content/${savedIdentifier}`); + await page.waitForLoadState('domcontentloaded'); + await page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 }); + + await field.expectVisible(); + await field.expectImageInserted(image.identifier, image.inode, image.title); + }); + + test('the icon-only insert-image button has an accessible name @smoke', async ({ page }) => { + // It is the only affordance for inserting an image and carries no text, so without a name + // from the plugin's `tooltip` there is nothing for a screen reader to announce. + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new WysiwygField(page, WYSIWYG_FIELD_VARIABLE); + await field.expectVisible(); + + await field.expectInsertImageButtonLabelled(); + }); +}); diff --git a/core-web/apps/dotcms-ui/project.json b/core-web/apps/dotcms-ui/project.json index d23005532168..e1813c28348e 100644 --- a/core-web/apps/dotcms-ui/project.json +++ b/core-web/apps/dotcms-ui/project.json @@ -127,7 +127,7 @@ "serve": { "continuous": true, "executor": "@angular/build:dev-server", - "dependsOn": [], + "dependsOn": [{ "target": "build", "projects": ["dotcms-webcomponents"] }], "defaultConfiguration": "development", "options": { "proxyConfig": "apps/dotcms-ui/proxy-dev.conf.mjs", diff --git a/core-web/libs/data-access/src/lib/dot-content-drive/dot-content-drive.service.ts b/core-web/libs/data-access/src/lib/dot-content-drive/dot-content-drive.service.ts index 908b29faeb19..1bd7d3fca634 100644 --- a/core-web/libs/data-access/src/lib/dot-content-drive/dot-content-drive.service.ts +++ b/core-web/libs/data-access/src/lib/dot-content-drive/dot-content-drive.service.ts @@ -7,7 +7,9 @@ import { map } from 'rxjs/operators'; import { DotContentDriveSearchRequest, DotContentDriveSearchResponse } from '@dotcms/dotcms-models'; -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class DotContentDriveService { readonly #http = inject(HttpClient); diff --git a/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts b/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts index 5ef7f6d5c34e..ecba75e1a4ca 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts @@ -19,6 +19,15 @@ export type TreeNodeContentData = { path: string; hostname: string; id: string; + /** Folder inode — used by Content Drive / AssetPicker to open the content editor pre-selected. */ + inode?: string; + /** + * Folder upload preference (`DOTASSET`/`FILEASSET`, or `null`/absent for "ask each time"). + * Drives folder-aware Upload behavior in Content Drive. + */ + defaultBaseType?: string | null; + /** True when the node was selected from the folder list/table rather than the tree. */ + fromTable?: boolean; }; /** diff --git a/core-web/libs/dotcms-webcomponents/project.json b/core-web/libs/dotcms-webcomponents/project.json index c77ce92500aa..b6034b5b2608 100644 --- a/core-web/libs/dotcms-webcomponents/project.json +++ b/core-web/libs/dotcms-webcomponents/project.json @@ -29,6 +29,7 @@ }, "build": { "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/libs/dotcms-webcomponents"], "options": { "command": "pnpm exec stencil build --config libs/dotcms-webcomponents/stencil.config.ts --prod" } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts index 7de270e6fcd3..234e60cd8496 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.legacy-availability.spec.ts @@ -10,8 +10,10 @@ import { DialogService } from 'primeng/dynamicdialog'; import { DotAiService, DotMessageService, + DotSiteService, DotWorkflowActionsFireService } from '@dotcms/data-access'; +import { DotSite } from '@dotcms/dotcms-models'; import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotFileFieldComponent } from './dot-file-field.component'; @@ -39,6 +41,14 @@ import { DotFileFieldUiMessageComponent } from '../dot-file-field-ui-message/dot * `createComponentFactory` per file, and this scenario needs a factory that * omits the launcher token. */ +/** The AssetPicker needs a site to browse. */ +const SITE_MOCK: DotSite = { + identifier: 'site-1', + hostname: 'demo.dotcms.com', + aliases: null, + archived: false +}; + describe('DotFileFieldComponent — legacy host availability (no Angular launcher)', () => { let spectator: Spectator; @@ -47,6 +57,11 @@ describe('DotFileFieldComponent — legacy host availability (no Angular launche imports: [ReactiveFormsModule], componentMocks: [DotFileFieldPreviewComponent, DotFileFieldUiMessageComponent], providers: [ + // Deliberately NO Router and NO GlobalStore: the legacy Dojo host is a custom element + // bootstrapped without a router, so anything the component pulls in has to survive that. + mockProvider(DotSiteService, { + getCurrentSite: jest.fn().mockReturnValue(of(SITE_MOCK)) + }), FileFieldStore, mockProvider(DotFileFieldUploadService), mockProvider(DialogService), @@ -82,6 +97,21 @@ describe('DotFileFieldComponent — legacy host availability (no Angular launche spectator.detectChanges(); }; + it('constructs in a host with no Router, as the legacy custom element has none', () => { + // Regression: injecting `GlobalStore` here dragged in `withBreadcrumbs`, which does + // `inject(Router)` eagerly. `dotcms-binary-field-builder` bootstraps without a router, so + // the whole Binary Field blew up with NG0201 and rendered nothing in the Dojo editor. + expect(() => + createComponent({ + props: { + field: BINARY_FIELD_MOCK, + contentlet: createFakeContentlet({ [BINARY_FIELD_MOCK.variable]: null }), + hasError: false + } as never + }) + ).not.toThrow(); + }); + it('hides the editor for an Image field even when the asset is an image', () => { setReferencedImageAsset(IMAGE_FIELD_MOCK); expect(spectator.component.$canEditImage()).toBe(false); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts index ecfdd0da9a4f..3da5b7b9bfb2 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.spec.ts @@ -3,6 +3,7 @@ import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { DialogService } from 'primeng/dynamicdialog'; @@ -12,7 +13,8 @@ import { DotMessageService, DotWorkflowActionsFireService } from '@dotcms/data-access'; -import { DotGeneratedAIImage, PromptType } from '@dotcms/dotcms-models'; +import { DotGeneratedAIImage, DotSite, PromptType } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotFileFieldComponent } from './dot-file-field.component'; @@ -29,6 +31,14 @@ import { DotFileFieldPreviewComponent } from '../dot-file-field-preview/dot-file import { DotFileFieldUiMessageComponent } from '../dot-file-field-ui-message/dot-file-field-ui-message.component'; import { DotFormFileEditorComponent } from '../dot-form-file-editor/dot-form-file-editor.component'; +/** The AssetPicker needs a site to browse; GlobalStore supplies it. */ +const SITE_MOCK: DotSite = { + identifier: 'site-1', + hostname: 'demo.dotcms.com', + aliases: null, + archived: false +}; + describe('DotFileFieldComponent', () => { let spectator: Spectator; @@ -45,6 +55,7 @@ describe('DotFileFieldComponent', () => { imports: [ReactiveFormsModule], componentMocks: [DotFileFieldPreviewComponent, DotFileFieldUiMessageComponent], providers: [ + mockProvider(GlobalStore, { siteDetails: signal(SITE_MOCK) }), FileFieldStore, mockProvider(DotFileFieldUploadService), mockProvider(DialogService), diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts index fadcea6854ec..96c957d68dbe 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts @@ -21,11 +21,12 @@ import { ButtonModule } from 'primeng/button'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { TooltipModule } from 'primeng/tooltip'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { DotAiService, DotMessageService, + DotSiteService, DotWorkflowActionsFireService } from '@dotcms/data-access'; import { @@ -33,12 +34,16 @@ import { DotCMSContentTypeField, DotCMSTempFile, DotFileMetadata, - DotGeneratedAIImage + DotGeneratedAIImage, + DotSite } from '@dotcms/dotcms-models'; import { isImageFile } from '@dotcms/image-editor'; import { + ASSET_PICKER_TITLE_KEYS, + buildAssetPickerConfig, + buildAssetPickerDialogConfig, DotAIImagePromptComponent, - DotBrowserSelectorComponent, + DotAssetPickerComponent, DotDropZoneComponent, DotMessagePipe, DotSpinnerComponent, @@ -47,6 +52,7 @@ import { } from '@dotcms/ui'; import { getFileMetadata } from '@dotcms/utils'; +import { DotEditContentStore } from './../../../../store/edit-content.store'; import { LegacyDialogImageEditorLauncher, LegacyDojoImageEditorLauncher @@ -150,6 +156,14 @@ export class DotFileFieldComponent * editor (SVGs), mirroring the store's editableAsText hydration. */ readonly #http = inject(HttpClient); + /** Site the AssetPicker browses. Root-provided, so always available. */ + readonly #siteService = inject(DotSiteService); + /** + * Supplies the locale when there is no contentlet yet (creating). Injected as `{ optional: true }` + * because only the Angular edit-content layout provides it — the legacy web-component host + * ({@link DotBinaryFieldCeBridgeComponent}) builds this same component without it. + */ + readonly #editContentStore = inject(DotEditContentStore, { optional: true }); /** * Reference to the dynamic dialog. It can be null if no dialog is currently open. * @@ -287,6 +301,22 @@ export class DotFileFieldComponent return ''; }); + /** + * Locale the AssetPicker pre-selects. + * + * The contentlet's own language wins when editing. When creating there is no contentlet yet, so + * it falls back to the locale currently selected in the editor — without that fallback the + * picker would open unfiltered on every new contentlet. + * + * @returns {string | undefined} the language id as a string, or `undefined` when neither source has one + */ + $pickerLanguageId = computed(() => { + const languageId = + this.$contentlet()?.languageId ?? this.#editContentStore?.currentLocale()?.id; + + return languageId ? String(languageId) : undefined; + }); + constructor() { super(); this.handleStoreValueChange(this.store.value); @@ -787,14 +817,22 @@ export class DotFileFieldComponent }); } /** - * Shows the select existing file dialog. + * Opens the AssetPicker to choose an asset that already exists in the system. * - * If the field is disabled, nothing happens. - * Opens the dialog with the `DotSelectExistingFileComponent` component - * and passes the field type and accepted files as data to the component. + * The picker is a compact Content Drive scoped to what this field can hold: an Image field + * narrows it to the dotAsset / File Asset base types and silently to images, a File field + * doesn't narrow it at all. It browses `api/v1/drive/search`, unlike the browser selector the + * block editor and custom fields still use. * - * When the dialog is closed, gets the uploaded file from the component - * and sets it as the preview file in the store. + * Nothing happens when the field is disabled, or when no site resolves — the picker would have + * nothing to browse. + * + * The site comes from `DotSiteService`, deliberately not from `GlobalStore`: this component also + * renders inside the legacy Dojo editor as the `dotcms-binary-field` custom element, which + * bootstraps without a router and without the app-shell providers. `GlobalStore` composes + * `withSystem`/`withBreadcrumbs`, so injecting it there threw NG0201 (`DotSystemConfigService`, + * then `Router`) and the whole Binary Field rendered blank. One HTTP call on an explicit click + * is a cheap price for a component that has to run in both hosts. * * @memberof DotEditContentFileFieldComponent */ @@ -803,42 +841,45 @@ export class DotFileFieldComponent return; } - const fieldType = this.$field().fieldType; - const title = - fieldType === INPUT_TYPES.Image - ? 'dot.file.field.dialog.select.existing.image.header' - : 'dot.file.field.dialog.select.existing.file.header'; - const mimeTypes = fieldType === INPUT_TYPES.Image ? ['image'] : []; - - const header = this.#dotMessageService.get(title); + this.#siteService + .getCurrentSite() + .pipe(take(1), takeUntilDestroyed(this.#destroyRef)) + .subscribe({ + next: (site) => { + // Opening a picker that can't browse anything is worse than not opening it. + if (site) { + this.#openAssetPicker(site); + } + }, + // Nothing to browse and nothing to say beyond that — the picker simply doesn't open. + error: () => { + /* noop */ + } + }); + } - this.#dialogRef = this.#dialogService.open(DotBrowserSelectorComponent, { - header, - appendTo: 'body', - closeOnEscape: true, - closable: true, - dismissableMask: true, - draggable: false, - keepInViewport: false, - maskStyleClass: 'p-dialog-mask-dynamic', - resizable: false, - modal: true, - width: '90%', - style: { 'max-width': '1040px' }, - contentStyle: { overflow: 'auto', 'min-height': '45rem' }, - data: { - mimeTypes, - showLinks: false, - showDotAssets: true, - showPages: false, - showFiles: true, - showFolders: false, - showWorking: true, - showArchived: false, - sortByDesc: true - } - }); + /** Opens the picker for a resolved site. Split out so the site lookup above stays readable. */ + #openAssetPicker(site: DotSite) { + const isImage = this.$field().fieldType === INPUT_TYPES.Image; + + const mode = isImage ? 'image' : 'file'; + + this.#dialogRef = this.#dialogService.open( + DotAssetPickerComponent, + // Dialog flags live with the picker — they are its contract, not this field's taste. + buildAssetPickerDialogConfig( + // No explicit path: the picker reopens on the globally remembered folder. + buildAssetPickerConfig({ + mode, + site, + title: this.#dotMessageService.get(ASSET_PICKER_TITLE_KEYS[mode]), + languageId: this.$pickerLanguageId() + }) + ) + ); + // Unchanged from the browser-selector era: both dialogs close with the same hydrated + // contentlet, so everything downstream of here keeps working as-is. this.#dialogRef.onClose .pipe( filter((file) => !!file), diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts index 046208d46459..beaf283d5427 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/dot-edit-content-file-field.component.spec.ts @@ -5,18 +5,25 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Component } from '@angular/core'; -import { DialogService } from 'primeng/dynamicdialog'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotAiService, DotContentletService, DotMessageService, + DotSiteService, DotUploadFileService, DotUploadService, DotWorkflowActionsFireService } from '@dotcms/data-access'; -import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; -import { DotDropZoneComponent, DropZoneErrorType, DropZoneFileEvent } from '@dotcms/ui'; +import { DotCMSContentTypeField, DotCMSContentlet, DotSite } from '@dotcms/dotcms-models'; +import { + DotAssetPickerComponent, + DotAssetPickerConfig, + DotDropZoneComponent, + DropZoneErrorType, + DropZoneFileEvent +} from '@dotcms/ui'; import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotFileFieldComponent } from './components/dot-file-field/dot-file-field.component'; @@ -50,6 +57,14 @@ const mockLauncher = { open: jest.fn().mockReturnValue(of(null)) }; +/** The AssetPicker needs a site to browse. */ +const SITE_MOCK: DotSite = { + identifier: 'site-1', + hostname: 'demo.dotcms.com', + aliases: null, + archived: false +}; + describe('DotFileFieldComponent', () => { let spectator: SpectatorHost; let store: InstanceType; @@ -70,6 +85,9 @@ describe('DotFileFieldComponent', () => { // We also provide them (and mock the upload service's transitive deps) at // the module level so the harness can resolve them, then spy per test. providers: [ + mockProvider(DotSiteService, { + getCurrentSite: jest.fn().mockReturnValue(of(SITE_MOCK)) + }), FileFieldStore, DialogService, DotFileFieldUploadService, @@ -602,4 +620,200 @@ describe('DotFileFieldComponent', () => { expect(dialogLauncher.open).not.toHaveBeenCalled(); }); }); + + describe('select existing asset (AssetPicker)', () => { + /** The site signal is created once by the factory, so a test that nulls it would leak. */ + /** + * `DotSiteService` is root-provided and mocked once for the file, so re-seed the return + * value per test rather than mutating a signal. + */ + const setSite = (site: DotSite | null) => + (spectator.inject(DotSiteService).getCurrentSite as jest.Mock).mockReturnValue( + site ? of(site) : of(null) + ); + + const openPicker = (field: DotCMSContentTypeField, contentlet?: DotCMSContentlet) => { + setup(field, contentlet); + setSite(SITE_MOCK); + spectator.detectChanges(); + + const dialogService = spectator.inject(DialogService, true); + const spyOpen = jest.spyOn(dialogService, 'open').mockReturnValue({ + onClose: of(undefined), + close: jest.fn() + } as unknown as DynamicDialogRef); + + spectator.component.showSelectExistingFileDialog(); + + return spyOpen; + }; + + /** The picker config the dialog was opened with. */ + const configOf = (spyOpen: jest.SpyInstance): DotAssetPickerConfig => + spyOpen.mock.calls[0][1].data as DotAssetPickerConfig; + + /** The `DialogService.open` options, minus the picker config. */ + const optionsOf = (spyOpen: jest.SpyInstance) => spyOpen.mock.calls[0][1]; + + it('should open the AssetPicker, not the browser selector', () => { + const spyOpen = openPicker(FILE_FIELD_MOCK); + + expect(spyOpen).toHaveBeenCalledWith( + DotAssetPickerComponent, + expect.objectContaining({ data: expect.anything() }) + ); + }); + + describe('dialog chrome', () => { + it('should hide PrimeNG’s header so the picker can render its own', () => { + const options = optionsOf(openPicker(FILE_FIELD_MOCK)); + + expect(options.showHeader).toBe(false); + expect(options.header).toBeUndefined(); + }); + + it('should not autofocus on open', () => { + // Autofocus lands on the picker's search input and paints the theme's focus halo + // the moment the dialog appears. + expect(optionsOf(openPicker(FILE_FIELD_MOCK)).focusOnShow).toBe(false); + }); + + it('should let the picker fill the dialog so full screen can grow it', () => { + const options = optionsOf(openPicker(FILE_FIELD_MOCK)); + + expect(options.height).toBeTruthy(); + expect(options.contentStyle).toEqual( + expect.objectContaining({ height: '100%', padding: '0' }) + ); + }); + + it('should size the windowed dialog without an inline max-width', () => { + // An inline max-width survives `.p-dialog-maximized` (which only overrides + // width/height), so it would clamp the dialog once it goes full screen. + const options = optionsOf(openPicker(FILE_FIELD_MOCK)); + + expect(options.width).toBe('min(90vw, 114rem)'); + expect(options.style).toBeUndefined(); + }); + + it('should enable PrimeNG’s maximized state without adding its button', () => { + const options = optionsOf(openPicker(FILE_FIELD_MOCK)); + + expect(options.maximizable).toBe(true); + // PrimeNG renders the maximize button inside the header we hid. + expect(options.showHeader).toBe(false); + }); + }); + + describe('File field', () => { + it('should open in file mode: no type or mime restriction', () => { + const config = configOf(openPicker(FILE_FIELD_MOCK)); + + expect(config.baseTypes).toBeUndefined(); + expect(config.mimeTypes).toBeUndefined(); + }); + + it('should pass the site being edited', () => { + const config = configOf(openPicker(FILE_FIELD_MOCK)); + + expect(config.site).toEqual(SITE_MOCK); + }); + + it('should carry the "Add File" title in the config', () => { + const config = configOf(openPicker(FILE_FIELD_MOCK)); + + // The mocked message service returns a fixed string, so the assertion that + // distinguishes File from Image is which key was resolved. + expect(spectator.inject(DotMessageService).get).toHaveBeenCalledWith( + 'dot.asset.picker.header.file' + ); + expect(config.title).toBeTruthy(); + }); + }); + + describe('Image field', () => { + it('should restrict to the dotAsset and File Asset base types', () => { + const config = configOf(openPicker(IMAGE_FIELD_MOCK)); + + expect(config.baseTypes).toEqual(['DOTASSET', 'FILEASSET']); + }); + + it('should apply the image mime restriction', () => { + const config = configOf(openPicker(IMAGE_FIELD_MOCK)); + + expect(config.mimeTypes).toEqual(['image/*']); + }); + + it('should carry the "Add Image" title in the config', () => { + const config = configOf(openPicker(IMAGE_FIELD_MOCK)); + + expect(spectator.inject(DotMessageService).get).toHaveBeenCalledWith( + 'dot.asset.picker.header.image' + ); + expect(config.title).toBeTruthy(); + }); + }); + + describe('locale', () => { + it("should use the contentlet's language when editing", () => { + const config = configOf( + openPicker(FILE_FIELD_MOCK, createFakeContentlet({ languageId: 2 })) + ); + + expect(config.languageId).toBe('2'); + }); + }); + + describe('guards', () => { + it('should not open when no site has resolved yet', () => { + setup(FILE_FIELD_MOCK); + spectator.detectChanges(); + + // Cold start: no site resolves. + setSite(null); + + const dialogService = spectator.inject(DialogService, true); + const spyOpen = jest.spyOn(dialogService, 'open'); + + spectator.component.showSelectExistingFileDialog(); + + expect(spyOpen).not.toHaveBeenCalled(); + }); + }); + + describe('close contract', () => { + it('should set the preview from the returned contentlet', () => { + const asset = createFakeContentlet({ identifier: 'asset-1' }); + setup(FILE_FIELD_MOCK); + setSite(SITE_MOCK); + spectator.detectChanges(); + + const spySetPreview = jest.spyOn(spectator.component.store, 'setPreviewFile'); + jest.spyOn(spectator.inject(DialogService, true), 'open').mockReturnValue({ + onClose: of(asset), + close: jest.fn() + } as unknown as DynamicDialogRef); + + spectator.component.showSelectExistingFileDialog(); + + expect(spySetPreview).toHaveBeenCalledWith({ source: 'contentlet', file: asset }); + }); + + it('should leave the field untouched on cancel', () => { + setup(FILE_FIELD_MOCK); + setSite(SITE_MOCK); + spectator.detectChanges(); + + const spySetPreview = jest.spyOn(spectator.component.store, 'setPreviewFile'); + jest.spyOn(spectator.inject(DialogService, true), 'open').mockReturnValue({ + onClose: of(undefined), + close: jest.fn() + } as unknown as DynamicDialogRef); + + spectator.component.showSelectExistingFileDialog(); + + expect(spySetPreview).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.spec.ts index 3bdef2c829fb..6cf470ab4b73 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.spec.ts @@ -1,18 +1,27 @@ import { expect } from '@jest/globals'; import { createServiceFactory, SpectatorService } from '@openng/spectator'; import { MockComponent } from 'ng-mocks'; -import { of } from 'rxjs'; +import { Observable, of, Subject, throwError } from 'rxjs'; + +import { signal } from '@angular/core'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { DotPropertiesService, DotUploadFileService } from '@dotcms/data-access'; -import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotAssetSearchDialogComponent } from '@dotcms/ui'; +import { + DotMessageService, + DotPropertiesService, + DotSiteService, + DotUploadFileService +} from '@dotcms/data-access'; +import { DotCMSContentlet, DotSite } from '@dotcms/dotcms-models'; +import { DotAssetPickerComponent } from '@dotcms/ui'; import { EMPTY_CONTENTLET } from '@dotcms/utils-testing'; import { DotWysiwygPluginService } from './dot-wysiwyg-plugin.service'; import { formatDotImageNode } from './utils/editor.utils'; +import { DotEditContentStore } from '../../../store/edit-content.store'; + /** * This Mock is used to check we are sending the correct configuration to the editor * No need to mock all the methods and properties of the Editor @@ -56,6 +65,21 @@ class MockEditor { const MOCK_IMAGE_URL_PATTERN = '/dA/{shortyId}/{name}?language_id={languageId}'; +const SITE: DotSite = { + identifier: 'site-1', + hostname: 'dotcms.com', + aliases: null, + archived: false +}; + +const LOCALE_ID = 2; + +/** + * Swapped per test so the site lookup can be resolved, deferred or failed. Read lazily by the mock + * because `currentSite$` defers the call until the first picker opens. + */ +let siteSource: Observable; + describe('DotWysiwygPluginService', () => { let spectator: SpectatorService; let dialogService: DialogService; @@ -71,7 +95,7 @@ describe('DotWysiwygPluginService', () => { const createService = createServiceFactory({ service: DotWysiwygPluginService, - declarations: [MockComponent(DotAssetSearchDialogComponent)], + declarations: [MockComponent(DotAssetPickerComponent)], providers: [ DialogService, { @@ -85,11 +109,28 @@ describe('DotWysiwygPluginService', () => { useValue: { publishContent: jest.fn() } + }, + { + provide: DotSiteService, + useValue: { getCurrentSite: jest.fn(() => siteSource) } + }, + { + provide: DotMessageService, + useValue: { get: jest.fn((key: string) => key) } + }, + { + provide: DotEditContentStore, + useValue: { currentLocale: signal({ id: LOCALE_ID }) } } ] }); beforeEach(() => { + // The `useValue` mocks are one object shared by every `createService()` in this file, so call + // counts accumulate across tests unless they are cleared first. Before the service is built, + // since its constructor is itself a call worth counting. + jest.clearAllMocks(); + siteSource = of(SITE); spectator = createService(); dialogService = spectator.inject(DialogService); dotUploadFileService = spectator.inject(DotUploadFileService); @@ -97,6 +138,12 @@ describe('DotWysiwygPluginService', () => { editor = new MockEditor(); }); + /** Clicks the toolbar button the plugin registers. */ + const clickAddImage = () => { + spectator.service.initializePlugins(editor); + editor.ui.registry.getAll().buttons['dotAddImage'].onAction(); + }; + it('should request the image URL pattern', () => { expect(dotPropertiesService.getKey).toHaveBeenCalledWith('WYSIWYG_IMAGE_URL_PATTERN'); }); @@ -111,37 +158,36 @@ describe('DotWysiwygPluginService', () => { expect(spyOn).toHaveBeenCalledWith('drop', expect.any(Function)); expect(spyButton).toHaveBeenCalledWith('dotAddImage', { icon: 'image', + // TinyMCE turns `tooltip` into the button's `aria-label` and `title`. It is the only + // accessible name an icon-only button gets, so it is part of the contract. + tooltip: 'insert-image', onAction: expect.any(Function) }); }); - it('should open the dialog when the button is clicked', () => { + it('should open the shared asset picker when the button is clicked', () => { const spyDialog = jest.spyOn(dialogService, 'open').mockReturnValue({ onClose: of(EMPTY_CONTENTLET) } as DynamicDialogRef); const spyEditorInserContent = jest.spyOn(editor, 'insertContent'); - spectator.service.initializePlugins(editor); - - const button = editor.ui.registry.getAll().buttons['dotAddImage']; - const dialogConfig = { - header: 'Insert Image', - width: '800px', - height: '500px', - contentStyle: { padding: 0 }, - closable: true, - closeOnEscape: true, - dismissableMask: true, - data: { - assetType: 'image' - } - }; - - // Simulate the button click - button.onAction(); - - expect(spyDialog).toHaveBeenCalledWith(DotAssetSearchDialogComponent, dialogConfig); + clickAddImage(); + + expect(spyDialog).toHaveBeenCalledWith( + DotAssetPickerComponent, + // The dialog flags are the picker's own contract, asserted in its spec. What matters + // here is the payload this field is responsible for. + expect.objectContaining({ + showHeader: false, + data: expect.objectContaining({ + site: SITE, + mimeTypes: ['image/*'], + languageId: String(LOCALE_ID), + title: 'dot.asset.picker.header.image' + }) + }) + ); expect(spyEditorInserContent).toHaveBeenCalledWith( formatDotImageNode(MOCK_IMAGE_URL_PATTERN, EMPTY_CONTENTLET) ); @@ -156,12 +202,7 @@ describe('DotWysiwygPluginService', () => { const spyEditorInserContent = jest.spyOn(editor, 'insertContent'); - spectator.service.initializePlugins(editor); - - const button = editor.ui.registry.getAll().buttons['dotAddImage']; - - // Simulate the button click that opens the dialog - button.onAction(); + clickAddImage(); expect(spyDialog).toHaveBeenCalled(); expect(spyEditorInserContent).not.toHaveBeenCalled(); @@ -170,6 +211,68 @@ describe('DotWysiwygPluginService', () => { expect(editor.focus).toHaveBeenCalled(); }); + it('should not stack a second picker while the site lookup is still in flight', () => { + // The site lookup is what makes opening asynchronous, so "is a dialog open" is not a + // sufficient guard on its own — the ref does not exist yet while it runs. + const site$ = new Subject(); + siteSource = site$.asObservable(); + spectator = createService(); + const spyDialog = jest + .spyOn(spectator.inject(DialogService), 'open') + .mockReturnValue({ onClose: of(undefined) } as DynamicDialogRef); + + spectator.service.initializePlugins(editor); + const button = editor.ui.registry.getAll().buttons['dotAddImage']; + button.onAction(); + button.onAction(); + site$.next(SITE); + + expect(spyDialog).toHaveBeenCalledTimes(1); + }); + + it('should open again after the picker closed', () => { + const spyDialog = jest + .spyOn(dialogService, 'open') + .mockReturnValue({ onClose: of(undefined) } as DynamicDialogRef); + + clickAddImage(); + editor.ui.registry.getAll().buttons['dotAddImage'].onAction(); + + expect(spyDialog).toHaveBeenCalledTimes(2); + }); + + it('should not open a picker that has nothing to browse', () => { + siteSource = throwError(() => new Error('no site')); + spectator = createService(); + const spyDialog = jest.spyOn(spectator.inject(DialogService), 'open'); + + spectator.service.initializePlugins(editor); + editor.ui.registry.getAll().buttons['dotAddImage'].onAction(); + + expect(spyDialog).not.toHaveBeenCalled(); + }); + + it('should retry the site lookup after it failed', () => { + siteSource = throwError(() => new Error('no site')); + spectator = createService(); + const siteService = spectator.inject(DotSiteService); + + spectator.service.initializePlugins(editor); + const button = editor.ui.registry.getAll().buttons['dotAddImage']; + button.onAction(); + button.onAction(); + + // The busy flag has to be released on error, or the button is dead for the session. + expect(siteService.getCurrentSite).toHaveBeenCalledTimes(2); + }); + + it('should not request a site until an image is actually inserted', () => { + // Most editing sessions never insert one; the lookup is deferred until the first click. + spectator.service.initializePlugins(editor); + + expect(spectator.inject(DotSiteService).getCurrentSite).not.toHaveBeenCalled(); + }); + it('should upload the image when dropped', () => { const uploadRespMock: unknown = [{ '1234': EMPTY_CONTENTLET }]; const spyUpload = jest diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.ts index 5034b201cd7d..f21243bb2183 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-wysiwyg-field/dot-wysiwyg-plugin/dot-wysiwyg-plugin.service.ts @@ -1,3 +1,4 @@ +import { Observable, defer, shareReplay } from 'rxjs'; import { Editor } from 'tinymce'; import { DestroyRef, Injectable, NgZone, inject } from '@angular/core'; @@ -5,14 +6,26 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DialogService } from 'primeng/dynamicdialog'; -import { filter } from 'rxjs/operators'; - -import { DotPropertiesService, DotUploadFileService } from '@dotcms/data-access'; -import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotAssetSearchDialogComponent } from '@dotcms/ui'; +import { filter, take } from 'rxjs/operators'; + +import { + DotMessageService, + DotPropertiesService, + DotSiteService, + DotUploadFileService +} from '@dotcms/data-access'; +import { DotCMSContentlet, DotSite } from '@dotcms/dotcms-models'; +import { + ASSET_PICKER_TITLE_KEYS, + DotAssetPickerComponent, + buildAssetPickerConfig, + buildAssetPickerDialogConfig +} from '@dotcms/ui'; import { DEFAULT_IMAGE_URL_PATTERN, formatDotImageNode } from './utils/editor.utils'; +import { DotEditContentStore } from '../../../store/edit-content.store'; + /** * Service to initialize the plugins for the WYSIWYG editor * @@ -24,12 +37,32 @@ export class DotWysiwygPluginService { private readonly dialogService: DialogService = inject(DialogService); private readonly dotUploadFileService: DotUploadFileService = inject(DotUploadFileService); private readonly dotPropertiesService: DotPropertiesService = inject(DotPropertiesService); + private readonly dotMessageService = inject(DotMessageService); + private readonly siteService = inject(DotSiteService); private readonly ngZone: NgZone = inject(NgZone); + /** + * Optional for the same reason the File field's is: this service is also constructed by hosts + * that are not the Edit Content shell (legacy Dojo pages), where the store does not exist. + */ + private readonly editContentStore = inject(DotEditContentStore, { optional: true }); + private IMAGE_URL_PATTERN = DEFAULT_IMAGE_URL_PATTERN; private readonly destroyRef$ = inject(DestroyRef); + /** True while a picker is open or its site lookup is in flight — see {@link dotImageDialog}. */ + private imagePickerBusy = false; + + /** + * Site the picker browses, resolved once per editor instance — it cannot change while the field + * is mounted. `defer` keeps it lazy (most editing sessions never insert an image) and lets a + * failed lookup be retried on the next click instead of being cached as a permanent failure. + */ + private readonly currentSite$: Observable = defer(() => + this.siteService.getCurrentSite() + ).pipe(take(1), shareReplay({ bufferSize: 1, refCount: false })); + constructor() { this.dotPropertiesService .getKey('WYSIWYG_IMAGE_URL_PATTERN') @@ -61,45 +94,90 @@ export class DotWysiwygPluginService { private dotImagePlugin(editor: Editor): void { editor.ui.registry.addButton('dotAddImage', { icon: 'image', + // TinyMCE renders `tooltip` as both `title` and `aria-label` (silver theme's + // `getTooltipAttributes`). Without it this icon-only button has no accessible name at + // all — nothing for a screen reader to announce and nothing to hover. + tooltip: this.dotMessageService.get('insert-image'), onAction: () => this.dotImageDialog(editor) }); this.handleImageDrop(editor); } /** - * Open the image dialog + * Opens the shared asset picker, scoped to images — the same picker the Edit Content File and + * Image fields and the Story Block use, so browsing for an asset looks the same everywhere. + * + * The site lookup makes this asynchronous: `DotAssetPickerComponent` cannot be configured + * without a `DotSite` and this field holds none. That gap is why the busy flag exists rather than + * a plain "is a dialog open" check — the ref does not exist yet while the lookup is running, so + * two fast clicks on the toolbar button would otherwise stack two dialogs. + * + * If the site cannot be resolved, nothing opens: a picker that can't browse anything is worse + * than no picker. * * @private * @param {Editor} editor * @memberof DotWysiwygPluginService */ private dotImageDialog(editor: Editor): void { - this.ngZone.run(() => { - const ref = this.dialogService.open(DotAssetSearchDialogComponent, { - header: 'Insert Image', - width: '800px', - height: '500px', - contentStyle: { padding: 0 }, - closable: true, - closeOnEscape: true, - dismissableMask: true, - data: { - assetType: 'image' - } - }); + if (this.imagePickerBusy) { + return; + } - ref.onClose.subscribe((asset: DotCMSContentlet) => { - if (asset) { - editor.insertContent(formatDotImageNode(this.IMAGE_URL_PATTERN, asset)); + this.imagePickerBusy = true; + + this.currentSite$.pipe(takeUntilDestroyed(this.destroyRef$)).subscribe({ + next: (site) => { + if (!site) { + this.imagePickerBusy = false; + + return; } - // Return focus to the editor on every close (insert or dismiss via - // X, Esc or overlay mask) so the user is never left without focus. - editor.focus(); - }); + this.ngZone.run(() => this.openImagePicker(editor, site)); + }, + error: () => (this.imagePickerBusy = false) }); } + /** Opens the picker for a resolved site. Split out so the lookup above stays readable. */ + private openImagePicker(editor: Editor, site: DotSite): void { + const ref = this.dialogService.open( + DotAssetPickerComponent, + // Dialog flags live with the picker — they are its contract, not this field's taste. + buildAssetPickerDialogConfig( + buildAssetPickerConfig({ + mode: 'image', + site, + title: this.dotMessageService.get(ASSET_PICKER_TITLE_KEYS.image), + languageId: this.pickerLanguageId() + }) + ) + ); + + ref.onClose.subscribe((asset: DotCMSContentlet) => { + this.imagePickerBusy = false; + + if (asset) { + editor.insertContent(formatDotImageNode(this.IMAGE_URL_PATTERN, asset)); + } + + // Return focus to the editor on every close (insert or dismiss via + // X, Esc or overlay mask) so the user is never left without focus. + editor.focus(); + }); + } + + /** + * Locale to pre-select in the picker, from the contentlet being edited. `undefined` when there is + * no Edit Content store to ask, which leaves the picker unfiltered by locale. + */ + private pickerLanguageId(): string | undefined { + const languageId = this.editContentStore?.currentLocale()?.id; + + return languageId ? String(languageId) : undefined; + } + /** * Handle the drop event in the editor * diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.html new file mode 100644 index 000000000000..9fc1d2b5f5c0 --- /dev/null +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.html @@ -0,0 +1,12 @@ + + + {{ $fullscreenIcon() }} + + diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.spec.ts similarity index 61% rename from core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts rename to core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.spec.ts index a52c195c8a12..0d3e558e11f6 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.spec.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.spec.ts @@ -7,19 +7,19 @@ import { ButtonModule } from 'primeng/button'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotImageEditorHeaderComponent } from './dot-image-editor-header.component'; +import { DotImageEditorFullscreenToggleComponent } from './dot-image-editor-fullscreen-toggle.component'; import { imageEditorViewEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; -describe('DotImageEditorHeaderComponent', () => { - let spectator: Spectator; +describe('DotImageEditorFullscreenToggleComponent', () => { + let spectator: Spectator; let dispatcher: Dispatcher; const isFullscreen = signal(false); const createComponent = createComponentFactory({ - component: DotImageEditorHeaderComponent, + component: DotImageEditorFullscreenToggleComponent, imports: [ButtonModule, DotMessagePipe], componentProviders: [Dispatcher, mockProvider(ImageEditorStore, { isFullscreen })] }); @@ -31,29 +31,7 @@ describe('DotImageEditorHeaderComponent', () => { jest.spyOn(dispatcher, 'dispatch'); }); - it('should render the title', () => { - const header = spectator.query(byTestId('image-editor-header')); - - expect(header).toBeTruthy(); - expect(header).toHaveText('edit.content.image-editor.title'); - }); - - it('should expose the header, full-screen and close testids', () => { - expect(spectator.query(byTestId('image-editor-header'))).toBeTruthy(); - expect(spectator.query(byTestId('image-editor-fullscreen-btn'))).toBeTruthy(); - expect(spectator.query(byTestId('image-editor-close-btn'))).toBeTruthy(); - }); - - it('should emit close when the close button is clicked', () => { - const closeSpy = jest.spyOn(spectator.component.$close, 'emit'); - const button = spectator.query(byTestId('image-editor-close-btn'))?.querySelector('button'); - - spectator.click(button as HTMLElement); - - expect(closeSpy).toHaveBeenCalledTimes(1); - }); - - it('should dispatch fullscreenToggled when the full-screen button is clicked', () => { + it('should dispatch fullscreenToggled when clicked', () => { const button = spectator .query(byTestId('image-editor-fullscreen-btn')) ?.querySelector('button'); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.ts similarity index 53% rename from core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts rename to core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.ts index 21dc914d790e..43c02d656bf9 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component.ts @@ -1,6 +1,6 @@ import { injectDispatch } from '@ngrx/signals/events'; -import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ButtonModule } from 'primeng/button'; import { TooltipModule } from 'primeng/tooltip'; @@ -11,31 +11,36 @@ import { imageEditorViewEvents } from '../../store/image-editor.events'; import { ImageEditorStore } from '../../store/image-editor.store'; /** - * Header bar of the image editor dialog. Renders the editor title on the left and, - * on the right, the full-screen toggle next to a close icon button (grouped as the - * dialog's window controls). Close emits {@link DotImageEditorHeaderComponent.$close}; - * the full-screen toggle dispatches {@link imageEditorViewEvents} and the root - * component performs the actual dialog resize, reacting to `store.isFullscreen()`. + * Full-screen toggle for the image editor dialog, projected into the shared header's + * `[dialogHeaderActions]` slot — the shell owns the title and the close button, and this is the one + * control that is specific to the editor. + * + * It only dispatches {@link imageEditorViewEvents}; the root component performs the actual dialog + * resize, reacting to `store.isFullscreen()`. */ @Component({ - selector: 'dot-image-editor-header', + selector: 'dot-image-editor-fullscreen-toggle', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ButtonModule, TooltipModule, DotMessagePipe], - templateUrl: './dot-image-editor-header.component.html' + templateUrl: './dot-image-editor-fullscreen-toggle.component.html' }) -export class DotImageEditorHeaderComponent { +export class DotImageEditorFullscreenToggleComponent { /** Image editor state store, provided by the owning dialog component. */ protected readonly store = inject(ImageEditorStore); readonly #viewDispatch = injectDispatch(imageEditorViewEvents); - /** Emitted when the user clicks the close (✕) button. */ - $close = output({ alias: 'close' }); - /** Material Symbol ligature for the full-screen toggle, by current state. */ protected readonly $fullscreenIcon = computed(() => this.store.isFullscreen() ? 'close_fullscreen' : 'open_in_full' ); + /** i18n key for the toggle's label, by current state. */ + protected readonly $fullscreenLabelKey = computed(() => + this.store.isFullscreen() + ? 'edit.content.image-editor.fullscreen.exit.aria' + : 'edit.content.image-editor.fullscreen.enter.aria' + ); + /** Toggles the editor dialog between its windowed size and full-screen. */ protected toggleFullscreen(): void { this.#viewDispatch.fullscreenToggled(); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html deleted file mode 100644 index 0d13fa3ac61d..000000000000 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor-header/dot-image-editor-header.component.html +++ /dev/null @@ -1,39 +0,0 @@ -
-

{{ 'edit.content.image-editor.title' | dm }}

- -
- - - {{ $fullscreenIcon() }} - - - - - - close - - -
-
diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html index 7fa7901c4dfd..2b0d618656e6 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.html @@ -1,23 +1,39 @@ -
-
- -
+ + + + - + + +
+ - + +
+
-
+ @if (store.saveStatus() === 'error') { - } -
-
+ + diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss index 6b9de7f58daf..fd5f4cfa0ecc 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.scss @@ -1,43 +1,11 @@ +// The dialog sizes this host; `dot-dialog` inside resolves its own `h-full` against it. :host { display: block; height: 100%; } -// Two-dimensional shell: header and footer span both columns; the middle row holds -// the canvas (fluid) and the panels (fixed). minmax(0, …) lets the canvas contain and -// the panels scroll instead of forcing the tracks to grow. -.image-editor { - display: grid; - grid-template-columns: minmax(0, 1fr) 23rem; - grid-template-rows: auto minmax(0, 1fr) auto; - grid-template-areas: - "header header" - "canvas panels" - "footer footer"; - height: 100%; - overflow: hidden; -} - -.image-editor__header { - grid-area: header; -} - -.image-editor__canvas { - grid-area: canvas; - min-width: 0; - min-height: 0; -} - -.image-editor__panels { - grid-area: panels; - min-height: 0; -} - -.image-editor__footer { - grid-area: footer; -} - -// Respect the user's motion preference: disable the scale/fade entrance. +// Respect the user's motion preference: disable the scale/fade entrance. Needs `!important` to beat +// the animation Angular applies inline, which is why this rule can't move to a Tailwind host class. @media (prefers-reduced-motion: reduce) { :host { animation: none !important; diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts index baafe5a3ab79..4792ed77208f 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.spec.ts @@ -27,7 +27,7 @@ import { import { ImageEditorStore } from '../../store/image-editor.store'; import { DotImageEditorCanvasComponent } from '../dot-image-editor-canvas/dot-image-editor-canvas.component'; import { DotImageEditorFooterComponent } from '../dot-image-editor-footer/dot-image-editor-footer.component'; -import { DotImageEditorHeaderComponent } from '../dot-image-editor-header/dot-image-editor-header.component'; +import { DotImageEditorFullscreenToggleComponent } from '../dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component'; import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-image-editor-panels.component'; /** Builds the suite for a given set of open params, asserting the shared shell behavior. */ @@ -69,14 +69,16 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { saveError }) ], - // Isolate the shell from the children's own store/dispatch wiring. + // Isolate the editor from the children's own store/dispatch wiring. The shared + // `DotDialog*` shell is deliberately left real: the close button lives in it, and + // stubbing it would drop everything projected into its slots. overrideComponents: [ [ DotImageEditorComponent, { remove: { imports: [ - DotImageEditorHeaderComponent, + DotImageEditorFullscreenToggleComponent, DotImageEditorCanvasComponent, DotImageEditorPanelsComponent, DotImageEditorFooterComponent @@ -84,7 +86,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { }, add: { imports: [ - MockComponent(DotImageEditorHeaderComponent), + MockComponent(DotImageEditorFullscreenToggleComponent), MockComponent(DotImageEditorCanvasComponent), MockComponent(DotImageEditorPanelsComponent), MockComponent(DotImageEditorFooterComponent) @@ -95,6 +97,15 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { ] }); + /** + * The ✕ now belongs to the shared shell rather than to an editor-owned header component, + * so the close path is driven through the rendered button instead of an output. + */ + const clickHeaderClose = () => { + const button = spectator.query(byTestId('dialog-close-btn'))?.querySelector('button'); + spectator.click(button as HTMLElement); + }; + beforeEach(() => { // The DynamicDialogRef `close` mock is shared across tests in this // describe; clear it so prior tests' close() calls don't leak into the @@ -128,7 +139,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should render the root and the four child components', () => { expect(spectator.query(byTestId('image-editor-root'))).toExist(); - expect(spectator.query('dot-image-editor-header')).toExist(); + expect(spectator.query('dot-image-editor-fullscreen-toggle')).toExist(); expect(spectator.query('dot-image-editor-canvas')).toExist(); expect(spectator.query('dot-image-editor-panels')).toExist(); expect(spectator.query('dot-image-editor-footer')).toExist(); @@ -142,8 +153,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { }); it('should close with null when the header close is triggered and not dirty', () => { - const header = spectator.query(DotImageEditorHeaderComponent)!; - header.$close.emit(); + clickHeaderClose(); expect(dialogRef.close).toHaveBeenCalledWith(null); expect(confirmationService.confirm).not.toHaveBeenCalled(); @@ -160,7 +170,7 @@ function describeWith(label: string, data: ImageEditorOpenParams): void { it('should confirm before closing when there are unsaved edits', () => { isDirty.set(true); - spectator.query(DotImageEditorHeaderComponent)!.$close.emit(); + clickHeaderClose(); expect(confirmationService.confirm).toHaveBeenCalledTimes(1); expect(dialogRef.close).not.toHaveBeenCalled(); diff --git a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts index 9afe4c5806a4..866652748b36 100644 --- a/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts +++ b/core-web/libs/image-editor/src/lib/components/dot-image-editor/dot-image-editor.component.ts @@ -10,6 +10,13 @@ import { Dialog } from 'primeng/dialog'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; +import { + DotDialogComponent, + DotDialogContentComponent, + DotDialogFooterComponent, + DotDialogHeaderComponent, + DotMessagePipe +} from '@dotcms/ui'; import { imageEditorModalScaleFade } from '../../animations/image-editor.animations'; import { DIALOG_SIZE_TRANSITION, FULLSCREEN_DIALOG_STYLE } from '../../image-editor.constants'; @@ -21,7 +28,7 @@ import { import { ImageEditorStore } from '../../store/image-editor.store'; import { DotImageEditorCanvasComponent } from '../dot-image-editor-canvas/dot-image-editor-canvas.component'; import { DotImageEditorFooterComponent } from '../dot-image-editor-footer/dot-image-editor-footer.component'; -import { DotImageEditorHeaderComponent } from '../dot-image-editor-header/dot-image-editor-header.component'; +import { DotImageEditorFullscreenToggleComponent } from '../dot-image-editor-fullscreen-toggle/dot-image-editor-fullscreen-toggle.component'; import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-image-editor-panels.component'; /** @@ -36,7 +43,12 @@ import { DotImageEditorPanelsComponent } from '../dot-image-editor-panels/dot-im changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ConfirmDialogModule, - DotImageEditorHeaderComponent, + DotDialogComponent, + DotDialogHeaderComponent, + DotDialogContentComponent, + DotDialogFooterComponent, + DotMessagePipe, + DotImageEditorFullscreenToggleComponent, DotImageEditorCanvasComponent, DotImageEditorPanelsComponent, DotImageEditorFooterComponent diff --git a/core-web/libs/image-editor/src/lib/image-editor.constants.ts b/core-web/libs/image-editor/src/lib/image-editor.constants.ts index 1ee9bc78053b..08c622f2619d 100644 --- a/core-web/libs/image-editor/src/lib/image-editor.constants.ts +++ b/core-web/libs/image-editor/src/lib/image-editor.constants.ts @@ -81,18 +81,7 @@ export const IMAGE_EDITOR_PANEL_STATE_KEY = 'DOT_IMAGE_EDITOR_PANEL_STATE'; export const LIBVIPS_CONFIG_KEY = 'IMAGE_API_USE_LIBVIPS'; /** - * Inline `.p-dialog` style props applied when the editor 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. + * Full-screen dialog styling now lives in `@dotcms/ui`, shared with the AssetPicker. + * Re-exported here so existing imports keep resolving from this module. */ -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'; +export { DIALOG_SIZE_TRANSITION, FULLSCREEN_DIALOG_STYLE } from '@dotcms/ui'; diff --git a/core-web/libs/new-block-editor/CLAUDE.md b/core-web/libs/new-block-editor/CLAUDE.md index f1d51370d972..2af267004070 100644 --- a/core-web/libs/new-block-editor/CLAUDE.md +++ b/core-web/libs/new-block-editor/CLAUDE.md @@ -75,6 +75,7 @@ The lib follows a strict split: **data fetching** delegates to `@dotcms/data-acc | `DotContentTypeService` | Content type filtering for the slash-menu's content-type sub-picker (`filterContentTypes`) and per-type metadata reads (`getContentType`, used by `ContentletEditUrlService`). | | `DotContentSearchService` | Lucene search behind the slash-menu's contentlet drill-down (`/api/content/_search`). The editor-flavoured query string (`+contentType:X +languageId:Y +deleted:false +working:true +catchall:** title:''^15`) is built inline at the call site (`buildContentletByTypeQuery` in `slash-menu-catalog.ts`); the service itself stays generic. | | `DotLanguagesService` | Language metadata for the editor store (`getById`). | +| `DotSiteService` | `getCurrentSite()` for the asset pickers — `DotAssetPickerComponent` needs a `DotSite` to browse and the editor holds none of its own. | | `DotAiService` | AI text generation, AI image generation + publish, plugin status check. Identical surface to legacy block-editor usage. | | `DotUploadFileService` | Wrapped by the lib's local `DotUploadService` adapter (see below). | | `DotMessageService` | i18n. Used everywhere. | @@ -86,7 +87,7 @@ Do **not** create custom HTTP services in this lib for any of the above. If you | Service | Why it stays local | |---|---| | `EditorPopoverService` | Caret-anchored popover state (active id, anchor rect, per-popover payloads). Editor-only concern. | -| `EditorModalService` | Lifecycle for centered `DialogService.open()` modals (AI content, AI image, image / video pickers). Editor-only concern. | +| `EditorModalService` | Lifecycle for centered `DialogService.open()` modals (AI content, AI image, and the image / video / audio asset pickers). Editor-only concern. | | `EditorToolbarStore` | Signal mirror of TipTap mark/block/alignment state for the toolbar. Editor-only concern. | | `SlashMenuService` | Slash-menu catalog, filtering, sub-menu loading. Editor-only concern. | | `ContentletEditUrlService` | Resolves the legacy-vs-new content editor URL via per-content-type feature-flag cache. Caches the metadata read so repeated contentlet edits within one session don't re-hit the network. The wrapper exists *for* the cache; without it, every "Edit contentlet" click would re-fetch. | @@ -105,7 +106,7 @@ The editor uses two distinct overlay primitives. Pick by interaction model, not | Primitive | Use | Anchored to | Modality | Examples | |-----------|-----|-------------|----------|----------| | `` shell | Compact, caret-anchored, single form | Caret / trigger rect via `@floating-ui/dom` | Non-modal — no backdrop, no focus trap, click-outside dismisses | link, table, image-properties, emoji | -| PrimeNG `DialogService.open()` | Centered modal — large content, multi-pane, embeddable, or external library component | Viewport center | Modal — backdrop, focus trap, explicit close | AI content, AI image, image picker, video picker | +| PrimeNG `DialogService.open()` | Centered modal — large content, multi-pane, embeddable, or external library component | Viewport center | Modal — backdrop, focus trap, explicit close | AI content, AI image, asset picker (image / video / audio) | When an overlay has both an input area AND a result/preview area, default to a centered modal — caret-anchored popovers get cramped. @@ -123,10 +124,11 @@ Each popover content component: ### Centered modals via `DialogService.open()` (`EditorModalService`) -Every centered modal in the editor — AI content, AI image, image picker, video picker — is opened through PrimeNG's `DialogService.open()`, surfaced by `EditorModalService` (`services/editor-modal.service.ts`). One pattern, one teardown story: +Every centered modal in the editor — AI content, AI image, and the image / video / audio asset pickers — is opened through PrimeNG's `DialogService.open()`, surfaced by `EditorModalService` (`services/editor-modal.service.ts`). One pattern, one teardown story: - The editor component provides `DialogService` at the component scope so each editor instance gets its own dynamic-dialog factory. Provided in `editor.component.ts`. -- `EditorModalService` keeps one private `DynamicDialogRef` per modal kind, set to `null` between opens. +- `EditorModalService` keeps one live `DynamicDialogRef` per modal kind, cleared between opens (the three asset pickers share one `Map` keyed by media mode, since they are one flow parameterised by mimetype). +- The asset pickers additionally resolve the current site through `DotSiteService` before opening — `DotAssetPickerComponent` cannot be configured without a `DotSite`. It is cached per editor instance and fetched lazily on first open. That async gap is why they guard on a *pending* set as well as the live ref: without it two fast clicks both sail past the ref check and stack two dialogs. - Each `openX(editor)` method calls `dialogService.open(Component, config)` with the right `data` and subscribes to `dialogRef.onClose` to apply the result (insert nodes, mutate state) into the editor. - Modal components inject `DynamicDialogRef` and signal a result by calling `this.dialogRef.close(result)`. Cancel/Escape/X close with no value, which the `onClose` subscriber treats as "no-op". - `ngOnDestroy()` on the service closes every live ref so an editor unmount mid-dialog doesn't orphan an overlay. @@ -201,7 +203,7 @@ These slash entries do not map 1:1 to a node — they trigger flows that mutate | AI Image | Opens `DotAIImagePromptComponent` via `DialogService.open()` (centered modal). On accept, inserts a `dotImage` node. | | AI Content | Opens `AiContentDialogComponent` via `DialogService.open()` (centered modal). On insert, the generated HTML is parsed against the editor schema so each block becomes a normal editable node (paragraphs / headings / lists). Does NOT wrap in an `aiContent` block. | | Content type | Opens an in-place sub-menu of allowed content types, then a contentlet picker. Inserts a `dotContent` node. | -| Image / Video | Opens `DotBrowserSelectorComponent` via `DialogService.open()` (centered modal picker). Inserts the corresponding `dotImage` / `dotVideo` node. | +| Image / Video / Audio | Opens `DotAssetPickerComponent` via `DialogService.open()` (centered modal picker) — the same picker the Edit Content File and Image fields use, so browsing for an asset looks the same everywhere. Inserts the corresponding `dotImage` / `dotVideo` / `dotAudio` node. Config comes from `buildAssetPickerConfig` + `buildAssetPickerDialogConfig` (`@dotcms/ui`); the mode (`image` \| `video` \| `audio`) is what supplies the mimetype narrowing and the dialog title. | | Table / Link / Emoji | Opens a caret-anchored ``. Insert / mutate the corresponding node. | ### Customer-supplied remote commands (`customBlocks` field variable) diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts index ad0f2d0edc9a..6f5c9e269b08 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/slash-menu/slash-menu-catalog.ts @@ -361,8 +361,9 @@ export function createBaseBlockItems(dotMessageService: DotMessageService): Bloc * Slash entries that open an overlay before mutating the document. * * Table is a caret-anchored popover via {@link EditorPopoverService}. Image, Video, and Audio - * skip the popover entirely and open the centered `DotBrowserSelectorComponent` directly via - * {@link EditorModalService} (per design + PM call — no in-popover Upload / URL tabs). + * skip the popover entirely and open the centered `DotAssetPickerComponent` directly via + * {@link EditorModalService} — the same picker the Edit Content File and Image fields use (per + * design + PM call — no in-popover Upload / URL tabs). */ export function createSlashOverlayBlockItems( popovers: EditorPopoverService, diff --git a/core-web/libs/new-block-editor/src/lib/editor/config.utils.ts b/core-web/libs/new-block-editor/src/lib/editor/config.utils.ts index e9c0264e39d8..27a5269c20d7 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/config.utils.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/config.utils.ts @@ -1,5 +1,4 @@ import { OverlayOptions } from 'primeng/api'; -import { DynamicDialogConfig } from 'primeng/dynamicdialog'; /** * Base z-index for every body-portaled overlay the editor opens (PrimeNG `DialogService` @@ -22,48 +21,6 @@ export const FULLSCREEN_AWARE_OVERLAY_OPTIONS: OverlayOptions = { baseZIndex: OVERLAY_ABOVE_FULLSCREEN_Z_INDEX }; -/** - * Shared centered-modal configuration for editor dialogs that mount - * `DotBrowserSelectorComponent` from `@dotcms/ui` (currently the dotCMS image, - * video, and audio pickers). Locking sizing, mask styling, and the picker's - * data-payload defaults in one place keeps every browse-an-asset flow consistent - * and avoids drift when a new mime-type variant is added. - * - * Callers provide only the dialog header and the contentlet mime-type allowlist - * (e.g. `['image']`, `['video']`, or `['audio']`). Everything else mirrors the file-field's - * configuration so customers see the same UX whether they're picking an asset - * for a file field or for a Story Block. - */ -export function buildBrowserSelectorConfig(opts: { - header: string; - mimeTypes: string[]; -}): DynamicDialogConfig { - return { - header: opts.header, - appendTo: 'body', - // Modal picker must clear the fullscreen editor shell's `z-[9998]` backdrop. - baseZIndex: OVERLAY_ABOVE_FULLSCREEN_Z_INDEX, - closeOnEscape: true, - closable: true, - dismissableMask: true, - draggable: false, - keepInViewport: false, - maskStyleClass: 'p-dialog-mask-dynamic', - resizable: false, - modal: true, - width: '90%', - style: { 'max-width': '1040px', overflow: 'hidden' }, - contentStyle: { overflow: 'auto', 'min-height': 'min(45rem, 80vh)' }, - data: { - mimeTypes: opts.mimeTypes, - showLinks: false, - showDotAssets: true, - showPages: false, - showFiles: true, - showFolders: false, - showWorking: true, - showArchived: false, - sortByDesc: true - } - }; -} +// The image / video / audio pickers used to build their dialog config here. They now open +// `DotAssetPickerComponent` through `buildAssetPickerDialogConfig` from `@dotcms/ui`, which owns +// those flags because the picker depends on them — see `EditorModalService.openAssetPicker`. diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.spec.ts b/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.spec.ts index ecce51b5aad5..6e29ef3e4f89 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.spec.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.spec.ts @@ -4,19 +4,27 @@ import { SpectatorService, SpyObject } from '@openng/spectator/jest'; -import { Subject } from 'rxjs'; +import { Observable, of, Subject, throwError } from 'rxjs'; + +import { signal } from '@angular/core'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; import { Editor } from '@tiptap/core'; -import { DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotBrowserSelectorComponent } from '@dotcms/ui'; +import { DotMessageService, DotSiteService } from '@dotcms/data-access'; +import { DotCMSContentlet, DotSite } from '@dotcms/dotcms-models'; +import { DotAssetPickerComponent } from '@dotcms/ui'; import { EditorModalService } from './editor-modal.service'; -import { insertDotAudioFromContentlet } from '../editor.utils'; +import { OVERLAY_ABOVE_FULLSCREEN_Z_INDEX } from '../config.utils'; +import { + insertDotAudioFromContentlet, + insertDotImageFromContentlet, + insertDotVideoFromContentlet +} from '../editor.utils'; +import { EditorStore } from '../store/editor.store'; jest.mock('../editor.utils', () => ({ insertDotImageFromContentlet: jest.fn(), @@ -24,69 +32,212 @@ jest.mock('../editor.utils', () => ({ insertDotAudioFromContentlet: jest.fn() })); -const AUDIO_DIALOG_TITLE_KEY = 'dot.block-editor.extension.audio.dotcms.dialog-title'; +const SITE: DotSite = { + identifier: 'site-1', + hostname: 'dotcms.com', + aliases: null, + archived: false +}; + +const LANGUAGE_ID = 2; + +/** + * Swapped per test so the site lookup can be resolved, deferred or failed. Read lazily by the mock + * so it can be set before the service is constructed — `currentSite$` is a field initializer. + */ +let siteSource: Observable; -describe('EditorModalService — openAudioPicker', () => { +describe('EditorModalService — asset pickers', () => { let spectator: SpectatorService; let service: EditorModalService; let dialogService: SpyObject; let onClose$: Subject; + let closeSpy: jest.Mock; const editor = {} as Editor; - const insertAudioMock = insertDotAudioFromContentlet as jest.Mock; + + const insertImage = insertDotImageFromContentlet as jest.Mock; + const insertVideo = insertDotVideoFromContentlet as jest.Mock; + const insertAudio = insertDotAudioFromContentlet as jest.Mock; const createService = createServiceFactory({ service: EditorModalService, providers: [ mockProvider(DialogService), - mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }) + mockProvider(DotMessageService, { get: jest.fn((key: string) => key) }), + mockProvider(DotSiteService, { getCurrentSite: jest.fn(() => siteSource) }), + { provide: EditorStore, useValue: { languageId: signal(LANGUAGE_ID) } } ] }); - beforeEach(() => { - insertAudioMock.mockClear(); + /** Builds the service against whatever `siteSource` currently is. */ + const setup = () => { spectator = createService(); service = spectator.service; dialogService = spectator.inject(DialogService); onClose$ = new Subject(); + closeSpy = jest.fn(); dialogService.open.mockReturnValue({ onClose: onClose$.asObservable(), - close: jest.fn() + close: closeSpy } as unknown as DynamicDialogRef); + }; + + /** The config object handed to `DialogService.open` for the Nth call. */ + const openedConfig = (call = 0) => dialogService.open.mock.calls[call][1]; + + beforeEach(() => { + jest.clearAllMocks(); + siteSource = of(SITE); + }); + + describe.each([ + ['image', 'openImagePicker', ['image/*'], 'dot.asset.picker.header.image'], + ['video', 'openVideoPicker', ['video/*'], 'dot.asset.picker.header.video'], + ['audio', 'openAudioPicker', ['audio/*'], 'dot.asset.picker.header.audio'] + ] as const)('%s', (_mode, method, mimeTypes, titleKey) => { + beforeEach(() => { + setup(); + service[method](editor); + }); + + it('should open the shared asset picker', () => { + expect(dialogService.open).toHaveBeenCalledTimes(1); + expect(dialogService.open.mock.calls[0][0]).toBe(DotAssetPickerComponent); + }); + + it('should restrict the picker to its own mime types', () => { + expect(openedConfig().data.mimeTypes).toEqual(mimeTypes); + }); + + it('should title the picker for what it is picking', () => { + // The picker draws its own header, so the title travels in `data`, not `header`. + expect(openedConfig().data.title).toBe(titleKey); + expect(openedConfig().showHeader).toBe(false); + }); + + it('should browse the current site in the editor locale', () => { + expect(openedConfig().data.site).toBe(SITE); + expect(openedConfig().data.languageId).toBe(String(LANGUAGE_ID)); + }); + + it('should clear the fullscreen editor shell backdrop', () => { + // Without this the modal renders under the shell's `z-[9998]` and is unreachable. + expect(openedConfig().baseZIndex).toBe(OVERLAY_ABOVE_FULLSCREEN_Z_INDEX); + }); }); - it('opens the browser selector scoped to audio mime types', () => { - service.openAudioPicker(editor); + describe('inserting the picked asset', () => { + it.each([ + ['openImagePicker', () => insertImage, () => [insertVideo, insertAudio]], + ['openVideoPicker', () => insertVideo, () => [insertImage, insertAudio]], + ['openAudioPicker', () => insertAudio, () => [insertImage, insertVideo]] + ] as const)('should insert the node %s corresponds to', (method, expected, others) => { + setup(); + const contentlet = { identifier: 'id-1', inode: 'inode-1' } as DotCMSContentlet; + + service[method](editor); + onClose$.next(contentlet); - expect(dialogService.open).toHaveBeenCalledTimes(1); - const [component, config] = dialogService.open.mock.calls[0]; - expect(component).toBe(DotBrowserSelectorComponent); - expect(config.header).toBe(AUDIO_DIALOG_TITLE_KEY); - expect(config.data.mimeTypes).toEqual(['audio']); + expect(expected()).toHaveBeenCalledWith(editor, contentlet); + others().forEach((fn) => expect(fn).not.toHaveBeenCalled()); + }); + + it('should do nothing when the picker closes without a selection', () => { + setup(); + + service.openImagePicker(editor); + onClose$.next(undefined); + + expect(insertImage).not.toHaveBeenCalled(); + }); }); - it('inserts the picked contentlet as a dotAudio node on close', () => { - service.openAudioPicker(editor); - const contentlet = { identifier: 'id-1', inode: 'inode-1' } as DotCMSContentlet; + describe('opening twice', () => { + it('should not stack a second dialog while one is open', () => { + setup(); + + service.openImagePicker(editor); + service.openImagePicker(editor); - onClose$.next(contentlet); + expect(dialogService.open).toHaveBeenCalledTimes(1); + }); - expect(insertAudioMock).toHaveBeenCalledWith(editor, contentlet); + it('should not stack a second dialog while the site lookup is still in flight', () => { + // The guard the async site lookup makes necessary: the ref does not exist yet, so + // without a pending flag both clicks would sail past it. + const site$ = new Subject(); + siteSource = site$.asObservable(); + setup(); + + service.openImagePicker(editor); + service.openImagePicker(editor); + site$.next(SITE); + + expect(dialogService.open).toHaveBeenCalledTimes(1); + }); + + it('should open again after the first dialog closed', () => { + setup(); + + service.openImagePicker(editor); + onClose$.next(undefined); + service.openImagePicker(editor); + + expect(dialogService.open).toHaveBeenCalledTimes(2); + }); + + it('should let a different media type open alongside', () => { + setup(); + + service.openImagePicker(editor); + service.openVideoPicker(editor); + + expect(dialogService.open).toHaveBeenCalledTimes(2); + }); }); - it('does nothing when the picker closes without a selection', () => { - service.openAudioPicker(editor); + describe('site lookup', () => { + it('should resolve the site once for every picker', () => { + setup(); - onClose$.next(undefined); + service.openImagePicker(editor); + onClose$.next(undefined); + service.openVideoPicker(editor); - expect(insertAudioMock).not.toHaveBeenCalled(); + // Cached: the current site cannot change while the editor is mounted. + expect(spectator.inject(DotSiteService).getCurrentSite).toHaveBeenCalledTimes(1); + }); + + it('should not open a picker that has nothing to browse', () => { + siteSource = throwError(() => new Error('no site')); + setup(); + + service.openImagePicker(editor); + + expect(dialogService.open).not.toHaveBeenCalled(); + }); + + it('should retry the lookup after it failed', () => { + siteSource = throwError(() => new Error('no site')); + setup(); + + service.openImagePicker(editor); + service.openImagePicker(editor); + + // The pending flag has to be released on error, or the picker is dead for the session. + expect(spectator.inject(DotSiteService).getCurrentSite).toHaveBeenCalledTimes(2); + }); }); - it('is idempotent while the picker is already open', () => { - service.openAudioPicker(editor); - service.openAudioPicker(editor); + it('should close every open picker when the editor unmounts', () => { + setup(); + + service.openImagePicker(editor); + service.openVideoPicker(editor); + service.ngOnDestroy(); - expect(dialogService.open).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.ts index f83bc259ef40..281c72782b27 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/services/editor-modal.service.ts @@ -1,20 +1,43 @@ -import { Injectable, NgZone, OnDestroy, inject, signal } from '@angular/core'; +import { Observable, defer, shareReplay } from 'rxjs'; + +import { DestroyRef, Injectable, NgZone, OnDestroy, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { take } from 'rxjs/operators'; + import { Editor } from '@tiptap/core'; -import { DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentlet, DotGeneratedAIImage } from '@dotcms/dotcms-models'; -import { DotAIImagePromptComponent, DotBrowserSelectorComponent } from '@dotcms/ui'; +import { DotMessageService, DotSiteService } from '@dotcms/data-access'; +import { DotCMSContentlet, DotGeneratedAIImage, DotSite } from '@dotcms/dotcms-models'; +import { + ASSET_PICKER_TITLE_KEYS, + DotAIImagePromptComponent, + DotAssetPickerComponent, + DotAssetPickerMediaMode, + buildAssetPickerConfig, + buildAssetPickerDialogConfig +} from '@dotcms/ui'; import { AiContentDialogComponent } from '../components/ai-content-dialog/ai-content-dialog.component'; -import { OVERLAY_ABOVE_FULLSCREEN_Z_INDEX, buildBrowserSelectorConfig } from '../config.utils'; +import { OVERLAY_ABOVE_FULLSCREEN_Z_INDEX } from '../config.utils'; import { insertDotAudioFromContentlet, insertDotImageFromContentlet, insertDotVideoFromContentlet } from '../editor.utils'; +import { EditorStore } from '../store/editor.store'; + +/** Inserts the picked contentlet as the node the media mode corresponds to. */ +const INSERT_BY_MODE: Record< + DotAssetPickerMediaMode, + (editor: Editor, contentlet: DotCMSContentlet) => void +> = { + image: insertDotImageFromContentlet, + video: insertDotVideoFromContentlet, + audio: insertDotAudioFromContentlet +}; /** * Owns every centered modal dialog in the editor — all opened via PrimeNG's @@ -30,15 +53,37 @@ export class EditorModalService implements OnDestroy { private readonly zone = inject(NgZone); private readonly dialogService = inject(DialogService); private readonly dotMessageService = inject(DotMessageService); + private readonly siteService = inject(DotSiteService); + private readonly editorStore = inject(EditorStore); + private readonly destroyRef = inject(DestroyRef); - /** Live ref for the image picker; cleared when the dialog closes or the service tears down. */ - private imagePickerRef: DynamicDialogRef | null = null; + /** + * Live picker refs by media mode; an entry is cleared when its dialog closes or the service + * tears down. + */ + private pickerRefs = new Map(); - /** Live ref for the video picker; cleared when the dialog closes or the service tears down. */ - private videoPickerRef: DynamicDialogRef | null = null; + /** + * Modes whose site lookup is in flight. Separate from {@link pickerRefs} because the ref only + * exists once the site resolves — without this, two fast clicks each pass the ref guard and open + * two dialogs. + */ + private pickerPending = new Set(); - /** Live ref for the audio picker; cleared when the dialog closes or the service tears down. */ - private audioPickerRef: DynamicDialogRef | null = null; + /** + * The site the picker browses, resolved once per editor instance. + * + * Cached rather than re-fetched per open: the current site cannot change while the editor is + * mounted, so three pickers asking separately would be three requests for one answer. + * `refCount: false` keeps the value once the first subscriber has gone away. + * + * `defer` so nothing is requested until a picker is actually opened — most editing sessions never + * open one — and so a failed lookup is retried on the next attempt instead of being cached as a + * permanent failure (`shareReplay` resets itself on error). + */ + private readonly currentSite$: Observable = defer(() => + this.siteService.getCurrentSite() + ).pipe(take(1), shareReplay({ bufferSize: 1, refCount: false })); /** * Open state for the AI Image prompt modal. Tracking it as a signal lets other parts @@ -53,80 +98,80 @@ export class EditorModalService implements OnDestroy { private aiContentDialogRef: DynamicDialogRef | null = null; /** - * Opens {@link DotBrowserSelectorComponent} scoped to image-mime contentlets. On accept, - * inserts the picked contentlet as a `dotImage` node at the editor's current selection. - * Idempotent: a second call while the picker is already open is a no-op. + * Opens {@link DotAssetPickerComponent} scoped to image-mime contentlets. On accept, inserts the + * picked contentlet as a `dotImage` node at the editor's current selection. */ openImagePicker(editor: Editor): void { - if (this.imagePickerRef) return; - - this.imagePickerRef = this.dialogService.open( - DotBrowserSelectorComponent, - buildBrowserSelectorConfig({ - header: this.dotMessageService.get( - 'dot.block-editor.extension.image.dotcms.dialog-title' - ), - mimeTypes: ['image'] - }) - ); - - this.imagePickerRef.onClose.subscribe((contentlet?: DotCMSContentlet) => { - if (contentlet) { - this.zone.run(() => insertDotImageFromContentlet(editor, contentlet)); - } - this.imagePickerRef = null; - }); + this.openAssetPicker(editor, 'image'); } /** - * Opens {@link DotBrowserSelectorComponent} scoped to video-mime contentlets. On accept, - * inserts the picked contentlet as a `dotVideo` node at the editor's current selection. - * Idempotent: a second call while the picker is already open is a no-op. + * Opens {@link DotAssetPickerComponent} scoped to video-mime contentlets. On accept, inserts the + * picked contentlet as a `dotVideo` node at the editor's current selection. */ openVideoPicker(editor: Editor): void { - if (this.videoPickerRef) return; - - this.videoPickerRef = this.dialogService.open( - DotBrowserSelectorComponent, - buildBrowserSelectorConfig({ - header: this.dotMessageService.get( - 'dot.block-editor.extension.video.dotcms.dialog-title' - ), - mimeTypes: ['video'] - }) - ); - - this.videoPickerRef.onClose.subscribe((contentlet?: DotCMSContentlet) => { - if (contentlet) { - this.zone.run(() => insertDotVideoFromContentlet(editor, contentlet)); - } - this.videoPickerRef = null; - }); + this.openAssetPicker(editor, 'video'); } /** - * Opens {@link DotBrowserSelectorComponent} scoped to audio-mime contentlets. On accept, - * inserts the picked contentlet as a `dotAudio` node at the editor's current selection. - * Idempotent: a second call while the picker is already open is a no-op. + * Opens {@link DotAssetPickerComponent} scoped to audio-mime contentlets. On accept, inserts the + * picked contentlet as a `dotAudio` node at the editor's current selection. */ openAudioPicker(editor: Editor): void { - if (this.audioPickerRef) return; - - this.audioPickerRef = this.dialogService.open( - DotBrowserSelectorComponent, - buildBrowserSelectorConfig({ - header: this.dotMessageService.get( - 'dot.block-editor.extension.audio.dotcms.dialog-title' - ), - mimeTypes: ['audio'] - }) + this.openAssetPicker(editor, 'audio'); + } + + /** + * The one asset-picker flow, shared by image, video and audio — the same picker the Edit Content + * File and Image fields open, so browsing for an asset looks the same everywhere. + * + * The site lookup makes this asynchronous, which is the only real wrinkle: the picker cannot be + * configured without a `DotSite`, and there is nowhere in the editor that already holds one. A + * mode with a lookup in flight or a dialog already open is skipped, so repeated clicks can never + * stack two pickers. + * + * If the site cannot be resolved, nothing opens. A picker that can't browse anything is worse + * than no picker, and there is nothing useful to say about it beyond that. + */ + private openAssetPicker(editor: Editor, mode: DotAssetPickerMediaMode): void { + if (this.pickerRefs.has(mode) || this.pickerPending.has(mode)) return; + + this.pickerPending.add(mode); + + this.currentSite$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ + next: (site) => { + this.pickerPending.delete(mode); + if (site) { + this.zone.run(() => this.mountAssetPicker(editor, mode, site)); + } + }, + error: () => this.pickerPending.delete(mode) + }); + } + + /** Opens the dialog for a resolved site. Split out so the lookup above stays readable. */ + private mountAssetPicker(editor: Editor, mode: DotAssetPickerMediaMode, site: DotSite): void { + const ref = this.dialogService.open( + DotAssetPickerComponent, + buildAssetPickerDialogConfig( + buildAssetPickerConfig({ + mode, + site, + title: this.dotMessageService.get(ASSET_PICKER_TITLE_KEYS[mode]), + languageId: String(this.editorStore.languageId()) + }), + // The fullscreen editor shell's `z-[9998]` backdrop would otherwise cover the modal. + { baseZIndex: OVERLAY_ABOVE_FULLSCREEN_Z_INDEX } + ) ); - this.audioPickerRef.onClose.subscribe((contentlet?: DotCMSContentlet) => { + this.pickerRefs.set(mode, ref); + + ref.onClose.subscribe((contentlet?: DotCMSContentlet) => { if (contentlet) { - this.zone.run(() => insertDotAudioFromContentlet(editor, contentlet)); + this.zone.run(() => INSERT_BY_MODE[mode](editor, contentlet)); } - this.audioPickerRef = null; + this.pickerRefs.delete(mode); }); } @@ -212,12 +257,11 @@ export class EditorModalService implements OnDestroy { } ngOnDestroy(): void { - this.imagePickerRef?.close(); - this.imagePickerRef = null; - this.videoPickerRef?.close(); - this.videoPickerRef = null; - this.audioPickerRef?.close(); - this.audioPickerRef = null; + for (const ref of this.pickerRefs.values()) { + ref.close(); + } + this.pickerRefs.clear(); + this.pickerPending.clear(); this.aiImageDialogRef?.close(); this.aiImageDialogRef = null; this.aiContentDialogRef?.close(); 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.ts b/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.ts deleted file mode 100644 index 729b10877efd..000000000000 --- 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.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; - -import { DotFolderTreeNodeData } from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { UPLOAD_SELECTOR_OPTIONS } from '../../../shared/constants'; -import { - DotContentDriveUploadBaseType, - DotContentDriveUploadSelection -} from '../../../shared/models'; - -/** - * Content Drive upload selector: lets the user pick whether the upload is created as an Asset - * (`DOTASSET`) or a File (`FILEASSET`). Rendered in the Upload-button popover and the drag-and-drop - * modal with the same click-and-go menu. - * - * Each option is a single click — choosing one emits the full - * {@link DotContentDriveUploadSelection} (target folder + chosen base type + the files, when - * already known) so the shell 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-content-drive-dialog-upload-selector', - imports: [DotMessagePipe], - templateUrl: './dot-content-drive-dialog-upload-selector.component.html', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotContentDriveDialogUploadSelectorComponent { - /** 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: DotContentDriveUploadBaseType): void { - this.selectUploadType.emit({ - targetFolder: this.$targetFolder(), - baseType, - files: this.$files() - }); - } -} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index 51c8153e2c62..dd09997836cb 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -15,10 +15,10 @@ import { DotFolderTreeNodeContentData, DotFolderTreeNodeItem, DotContentDriveMoveItems, - ALL_FOLDER, LOAD_MORE_NODE_TYPE } from '@dotcms/portlets/content-drive/ui'; import { GlobalStore } from '@dotcms/store'; +import { ALL_FOLDER } from '@dotcms/ui'; import { DotContentDriveSidebarComponent } from './dot-content-drive-sidebar.component'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index 5470db8b726a..ab264f96a4ae 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -20,7 +20,6 @@ import type { import { DotContentDriveActionableFolder, TreeNodeLoadMoreData } from '@dotcms/dotcms-models'; import { - ALL_FOLDER, DotContentDriveMoveItems, DotContentDriveTreeRightClick, DotContentDriveUploadFiles, @@ -29,6 +28,7 @@ import { DotTreeFolderComponent, LOAD_MORE_NODE_TYPE } from '@dotcms/portlets/content-drive/ui'; +import { ALL_FOLDER } from '@dotcms/ui'; import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.spec.ts index 8f57c0058531..c33c797c22d5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.spec.ts @@ -1,121 +1,35 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals'; -import { patchState } from '@ngrx/signals'; -import { - byTestId, - createComponentFactory, - mockProvider, - Spectator, - SpyObject -} from '@openng/spectator/jest'; -import { of, throwError } from 'rxjs'; +import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@openng/spectator/jest'; +import { of } from 'rxjs'; import { provideHttpClient } from '@angular/common/http'; -import { DebugElement } from '@angular/core'; import { By } from '@angular/platform-browser'; -import { Listbox } from 'primeng/listbox'; -import { Popover } from 'primeng/popover'; - import { DotContentTypeService, DotMessageService } from '@dotcms/data-access'; -import { - DotCMSBaseTypesContentTypes, - DotCMSContentType, - StructureTypeView -} from '@dotcms/dotcms-models'; -import { DotChipFilterComponent } from '@dotcms/portlets/content-drive/ui'; +import { DotContentTypeFilterComponent } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotContentDriveContentTypeFilterComponent } from './dot-content-drive-content-type-filter.component'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; -const BASE_TYPES: StructureTypeView[] = [ - { name: 'CONTENT', label: 'Content', types: [] }, - { name: 'FILEASSET', label: 'File', types: [] }, - { name: 'HTMLPAGE', label: 'Page', types: [] }, - { name: 'WIDGET', label: 'Widget', types: [] }, - { name: 'FORM', label: 'Form', types: [] } -]; - -const CONTENT_TYPES: DotCMSContentType[] = [ - { - id: '1', - name: 'Blog', - variable: 'blog', - baseType: 'CONTENT', - system: false - } as DotCMSContentType, - { - id: '2', - name: 'Banner', - variable: 'banner', - baseType: 'CONTENT', - system: false - } as DotCMSContentType, - { - id: '3', - name: 'Video File', - variable: 'videoFile', - baseType: 'FILEASSET', - system: false - } as DotCMSContentType, - { - id: '4', - name: 'Code', - variable: 'code', - baseType: 'FILEASSET', - system: false - } as DotCMSContentType, - { - id: '5', - name: 'Landing', - variable: 'landing', - baseType: 'HTMLPAGE', - system: false - } as DotCMSContentType, - { - id: '6', - name: 'Form A', - variable: 'formA', - baseType: 'FORM', - system: false - } as DotCMSContentType, - { - id: '7', - name: 'Sys', - variable: 'sys', - baseType: 'CONTENT', - system: true - } as DotCMSContentType -]; - describe('DotContentDriveContentTypeFilterComponent', () => { let spectator: Spectator; let store: SpyObject>; - let contentTypeService: SpyObject; - - const filtersSnapshot = jest.fn().mockReturnValue({}); const createComponent = createComponentFactory({ component: DotContentDriveContentTypeFilterComponent, providers: [ mockProvider(DotContentDriveStore, { - filters: filtersSnapshot, getFilterValue: jest.fn().mockReturnValue(undefined), patchFilters: jest.fn(), removeFilter: jest.fn() }), mockProvider(DotContentTypeService, { - getAllContentTypes: jest.fn().mockReturnValue(of(BASE_TYPES)), + getAllContentTypes: jest.fn().mockReturnValue(of([])), getContentTypesWithPagination: jest.fn().mockReturnValue( of({ - contentTypes: CONTENT_TYPES, - pagination: { - currentPage: 1, - perPage: 10, - totalEntries: CONTENT_TYPES.length, - totalPages: 1 - } + contentTypes: [], + pagination: { currentPage: 1, perPage: 10, totalEntries: 0 } }) ) }), @@ -124,13 +38,7 @@ describe('DotContentDriveContentTypeFilterComponent', () => { useValue: new MockDotMessageService({ 'content-drive.type-filter.title': 'Content Types', 'content-drive.type-filter.all-content-types': 'All Content Types', - 'content-drive.type-filter.all': 'All', - 'content-drive.type-filter.column.base-type': 'Base Type', - 'content-drive.type-filter.column.content-type': 'Content Type', - 'content-drive.content-type-field.empty-state': 'No content types found', - 'content-drive.chip-filter.overflow-label': '{0} and {1} more', - search: 'Search', - 'dot.common.remove': 'Remove' + search: 'Search' }) }, provideHttpClient() @@ -138,583 +46,103 @@ describe('DotContentDriveContentTypeFilterComponent', () => { detectChanges: false }); - /** - * Open the popover so the listboxes are rendered. Asserts $popoverOpen - * is actually `true` afterwards so the helper can't silently leave the - * panel hidden (which would make every assertion that follows trivially - * pass against an empty DOM). - */ - const openPopover = () => { - const chip = spectator.fixture.debugElement.query(By.directive(DotChipFilterComponent)); - spectator.triggerEventHandler(chip, 'clicked', new MouseEvent('click')); - spectator.detectChanges(); - expect(spectator.component.$popoverOpen()).toBe(true); - }; - - const findListbox = (predicate: (l: Listbox) => boolean): DebugElement => - spectator.fixture.debugElement - .queryAll(By.directive(Listbox)) - .find((de) => predicate(de.componentInstance as Listbox)) as DebugElement; - - const leftListbox = () => findListbox((l) => !l.multiple); - const rightListbox = () => findListbox((l) => l.multiple); - - const triggerFocusChange = (name: string | null) => { - spectator.triggerEventHandler(leftListbox(), 'ngModelChange', name); - spectator.detectChanges(); - }; - - /** - * Click the base-type checkbox. The component computes the next state - * from the current selection so the value emitted by p-checkbox is ignored - * (and intentionally not asserted on). - */ - const triggerBaseTypeToggle = (name: string) => { - spectator.triggerEventHandler(`[data-testid="base-type-checkbox-${name}"]`, 'onChange', { - checked: false - }); - spectator.detectChanges(); - }; - - const triggerContentTypeChange = (items: DotCMSContentType[] | null) => { - spectator.triggerEventHandler(rightListbox(), 'ngModelChange', items); - spectator.detectChanges(); - }; - - const triggerSearchInput = (value: string) => { - spectator.triggerEventHandler( - '[data-testid="content-type-search"]', - 'ngModelChange', - value - ); - spectator.detectChanges(); - }; - - const triggerLazyLoad = (event: { first: number; last: number }) => { - spectator.triggerEventHandler(rightListbox(), 'onLazyLoad', event); - spectator.detectChanges(); - }; - - const triggerPanelHide = () => { - const popover = spectator.fixture.debugElement.query(By.directive(Popover)); - spectator.triggerEventHandler(popover, 'onHide', undefined); - spectator.detectChanges(); - }; - - const triggerChipRemoved = () => { - const chip = spectator.fixture.debugElement.query(By.directive(DotChipFilterComponent)); - spectator.triggerEventHandler(chip, 'removed', undefined); - spectator.detectChanges(); - }; - - beforeAll(() => jest.useFakeTimers()); - afterAll(() => jest.useRealTimers()); + const contentTypeFilter = () => + spectator.fixture.debugElement.query(By.directive(DotContentTypeFilterComponent)); beforeEach(() => { - filtersSnapshot.mockReset(); - filtersSnapshot.mockReturnValue({}); spectator = createComponent(); store = spectator.inject(DotContentDriveStore, true); - contentTypeService = spectator.inject(DotContentTypeService, true); - // Reset implementations so per-test mockImplementation calls don't leak. store.getFilterValue.mockReset().mockReturnValue(undefined); - contentTypeService.getAllContentTypes.mockReset().mockReturnValue(of(BASE_TYPES)); - contentTypeService.getContentTypesWithPagination.mockReset().mockReturnValue( - of({ - contentTypes: CONTENT_TYPES, - pagination: { - currentPage: 1, - perPage: 10, - totalEntries: CONTENT_TYPES.length, - totalPages: 1 - } - }) - ); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.clearAllMocks(); }); - describe('Initialization', () => { - it('should load base types excluding FORM', () => { - spectator.detectChanges(); - expect(contentTypeService.getAllContentTypes).toHaveBeenCalled(); - expect(spectator.component.$state.baseTypes().map((b) => b.name)).toEqual([ - 'CONTENT', - 'FILEASSET', - 'HTMLPAGE', - 'WIDGET' - ]); - }); + afterEach(() => jest.clearAllMocks()); - it('should load initial content types excluding FORM and system', () => { - spectator.detectChanges(); - expect(contentTypeService.getContentTypesWithPagination).toHaveBeenCalledWith( - expect.objectContaining({ per_page: 10 }) - ); - const visible = spectator.component.$state.contentTypes(); - expect(visible.every((ct) => ct.baseType !== DotCMSBaseTypesContentTypes.FORM)).toBe( - true - ); - expect(visible.every((ct) => !ct.system)).toBe(true); - }); + it('should render the shared content-type filter', () => { + spectator.detectChanges(); - it('should default focus to ALL_CONTENT', () => { - spectator.detectChanges(); - expect(spectator.component.$focusedBaseType()).toBe('__ALL_CONTENT__'); - }); + expect(contentTypeFilter()).toBeTruthy(); + }); - it('should hydrate selected base types from the store', () => { + describe('store → filter (numeric keys to base-type names)', () => { + it('should decode the baseType filter into base-type names', () => { store.getFilterValue.mockImplementation(((key: string) => key === 'baseType' ? ['1', '4'] : undefined) as never); spectator.detectChanges(); - expect(spectator.component.$selectedBaseTypes()).toEqual(['CONTENT', 'FILEASSET']); + + expect(contentTypeFilter().componentInstance.$baseTypes()).toEqual([ + 'CONTENT', + 'FILEASSET' + ]); }); - it('should hydrate selected content types from the store via the cache', () => { + it('should pass the contentType variables straight through', () => { store.getFilterValue.mockImplementation(((key: string) => key === 'contentType' ? ['blog', 'videoFile'] : undefined) as never); spectator.detectChanges(); - expect( - spectator.component - .$selectedContentTypes() - .map((ct) => ct.variable) - .sort() - ).toEqual(['blog', 'videoFile']); - }); - }); - - describe('Chip label rules', () => { - beforeEach(() => spectator.detectChanges()); - - it('shows "Name (All)" when a base type is selected with no narrowed content types', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - expect(spectator.component.$chipSelections()).toEqual(['Content (All)']); - }); - - it('shows multiple base types each with "(All)" suffix', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT', 'HTMLPAGE']); - expect(spectator.component.$chipSelections()).toEqual(['Content (All)', 'Page (All)']); - }); - - it('shows specific content type names instead of "(All)" when narrowed', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - expect(spectator.component.$chipSelections()).toEqual(['Blog']); - }); - - it('mixes "(All)" and specific names across base types', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT', 'HTMLPAGE']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - expect(spectator.component.$chipSelections()).toEqual(['Blog', 'Page (All)']); - }); - }); - - describe('Cascade selection', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('adds the parent base type when a content type is selected (cascade up)', () => { - triggerContentTypeChange([CONTENT_TYPES[2]]); // Video File / FILEASSET - - expect(spectator.component.$selectedBaseTypes()).toContain('FILEASSET'); - expect(store.patchFilters).toHaveBeenCalledWith( - expect.objectContaining({ baseType: ['4'] }) - ); - expect(store.patchFilters).toHaveBeenCalledWith( - expect.objectContaining({ contentType: ['videoFile'] }) - ); - }); - - it('drops the parent base type when its last content type is unselected (cascade down)', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - spectator.detectChanges(); - - triggerContentTypeChange([]); - - expect(spectator.component.$selectedBaseTypes()).toEqual([]); - expect(store.removeFilter).toHaveBeenCalledWith('baseType'); - expect(store.removeFilter).toHaveBeenCalledWith('contentType'); - }); - - it('keeps the base type when other content types of it remain selected', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0], CONTENT_TYPES[1]]); - spectator.detectChanges(); - - triggerContentTypeChange([CONTENT_TYPES[1]]); // unselect Blog, keep Banner - - expect(spectator.component.$selectedBaseTypes()).toEqual(['CONTENT']); - }); - - it('clears the base AND its content types when a partial checkbox is clicked', () => { - // Partial = base selected with narrowing content types selected. - // Clicking it clears everything: users who see "some are - // selected" expect a click to clear, not to promote the - // selection to "all". - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - spectator.detectChanges(); - - triggerBaseTypeToggle('CONTENT'); - expect(spectator.component.$selectedBaseTypes()).toEqual([]); - expect(spectator.component.$selectedContentTypes()).toEqual([]); - expect(store.removeFilter).toHaveBeenCalledWith('baseType'); - expect(store.removeFilter).toHaveBeenCalledWith('contentType'); - }); - - it('does not auto-select content types when a base type is selected alone', () => { - triggerBaseTypeToggle('CONTENT'); - - expect(spectator.component.$selectedBaseTypes()).toEqual(['CONTENT']); - expect(spectator.component.$selectedContentTypes()).toEqual([]); - expect(store.patchFilters).toHaveBeenCalledWith( - expect.objectContaining({ baseType: ['1'] }) - ); - expect(store.removeFilter).toHaveBeenCalledWith('contentType'); + expect(contentTypeFilter().componentInstance.$contentTypes()).toEqual([ + 'blog', + 'videoFile' + ]); }); - it('drops the base when its fully-checked checkbox is clicked', () => { - // Fully checked = base selected with no narrowing content types. - spectator.component.$selectedBaseTypes.set(['CONTENT']); + it('should bind empty selections when no filters are set', () => { spectator.detectChanges(); - triggerBaseTypeToggle('CONTENT'); - - expect(spectator.component.$selectedBaseTypes()).toEqual([]); - expect(store.removeFilter).toHaveBeenCalledWith('baseType'); + expect(contentTypeFilter().componentInstance.$baseTypes()).toEqual([]); + expect(contentTypeFilter().componentInstance.$contentTypes()).toEqual([]); }); }); - describe('Focus vs selection', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('changes focus without altering selection', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.detectChanges(); - - triggerFocusChange('FILEASSET'); - - expect(spectator.component.$focusedBaseType()).toBe('FILEASSET'); - expect(spectator.component.$selectedBaseTypes()).toEqual(['CONTENT']); - }); - - it('falls back to ALL_CONTENT when focus is cleared', () => { - triggerFocusChange('FILEASSET'); - triggerFocusChange(null); - expect(spectator.component.$focusedBaseType()).toBe('__ALL_CONTENT__'); - }); - - it('refetches immediately with the focused base type as the type param', () => { - jest.clearAllMocks(); - triggerFocusChange('FILEASSET'); - - expect(contentTypeService.getContentTypesWithPagination).toHaveBeenCalledWith( - expect.objectContaining({ type: 'FILEASSET', page: 1 }) - ); - }); - - it('refetches without a type param when focus is ALL_CONTENT', () => { - triggerFocusChange('FILEASSET'); - jest.clearAllMocks(); - - triggerFocusChange('__ALL_CONTENT__'); - - expect(contentTypeService.getContentTypesWithPagination).toHaveBeenCalledWith( - expect.objectContaining({ type: undefined, page: 1 }) - ); - }); + describe('filter → store (base-type names to numeric keys)', () => { + beforeEach(() => spectator.detectChanges()); - it('eagerly clears the right list when focus changes', () => { - patchState(spectator.component.$state, { - contentTypes: CONTENT_TYPES.slice(0, 3) + it('should encode base-type names as numeric keys', () => { + spectator.triggerEventHandler(contentTypeFilter(), 'selectionChange', { + baseTypes: ['CONTENT', 'FILEASSET'], + contentTypes: [] }); - // Make the service hang so we can observe the cleared state. - contentTypeService.getContentTypesWithPagination.mockReturnValue(of() as never); - - triggerFocusChange('FILEASSET'); - - expect(spectator.component.$state.contentTypes()).toEqual([]); - expect(spectator.component.$state.loading()).toBe(true); - }); - }); - - describe('Focus follows checkbox toggle', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('focuses a base type when its checkbox is checked (right list shows its content types)', () => { - triggerBaseTypeToggle('FILEASSET'); - - expect(spectator.component.$focusedBaseType()).toBe('FILEASSET'); - expect(contentTypeService.getContentTypesWithPagination).toHaveBeenCalledWith( - expect.objectContaining({ type: 'FILEASSET', page: 1 }) - ); - }); - - it('resets focus to ALL_CONTENT when the focused base type is unchecked', () => { - triggerBaseTypeToggle('CONTENT'); // check → focus CONTENT - expect(spectator.component.$focusedBaseType()).toBe('CONTENT'); - - triggerBaseTypeToggle('CONTENT'); // uncheck the focused base type - - expect(spectator.component.$focusedBaseType()).toBe('__ALL_CONTENT__'); - }); - - it('leaves focus untouched when a base type other than the focused one is unchecked', () => { - triggerBaseTypeToggle('CONTENT'); // check → focus CONTENT - triggerBaseTypeToggle('WIDGET'); // check → focus WIDGET - expect(spectator.component.$focusedBaseType()).toBe('WIDGET'); - - triggerBaseTypeToggle('CONTENT'); // uncheck the NON-focused base type - - expect(spectator.component.$focusedBaseType()).toBe('WIDGET'); - expect(spectator.component.$selectedBaseTypes()).toEqual(['WIDGET']); - }); - - it('stops mousedown propagation on the checkbox so a sibling popover stays dismissable', () => { - // Without this, PrimeNG's popover marks selfClick=true on the - // checkbox mousedown and never resets it (the click is - // stopPropagation'd), leaving this popover open when another chip - // is clicked. - const event = { stopPropagation: jest.fn() }; - - spectator.triggerEventHandler( - '[data-testid="base-type-checkbox-CONTENT"]', - 'mousedown', - event - ); - - expect(event.stopPropagation).toHaveBeenCalled(); - }); - }); - - describe('Indeterminate base-type checkbox', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('flags isBaseTypePartial when the base has narrowing content types', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - spectator.detectChanges(); - - expect(spectator.component['isBaseTypePartial']('CONTENT')).toBe(true); - }); - - it('is not partial when the base type is selected alone', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.detectChanges(); - - expect(spectator.component['isBaseTypePartial']('CONTENT')).toBe(false); - }); - - it('is not partial when the base type is not selected at all', () => { - expect(spectator.component['isBaseTypePartial']('CONTENT')).toBe(false); - }); - - it('paints the box + icon with checked-state tokens via [pt] in partial state', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - spectator.detectChanges(); - - const box = spectator.query( - '[data-testid="base-type-checkbox-CONTENT"] .p-checkbox-box' - ) as HTMLElement | null; - const icon = spectator.query( - '[data-testid="base-type-checkbox-CONTENT"] .p-checkbox-icon' - ) as HTMLElement | null; - - expect(box?.style.background).toContain('--p-checkbox-checked-background'); - expect(box?.style.borderColor).toContain('--p-checkbox-checked-border-color'); - expect(icon?.style.color).toContain('--p-checkbox-icon-checked-color'); - }); - }); - - describe('"All content types selected" banner', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('is hidden when focus is ALL_CONTENT', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.detectChanges(); - - expect(spectator.component['$showAllBanner']()).toBe(false); - }); - - it('shows when the focused base type is selected with no narrowing', () => { - spectator.component.$selectedBaseTypes.set(['FILEASSET']); - triggerFocusChange('FILEASSET'); - - expect(spectator.component['$showAllBanner']()).toBe(true); - }); - - it('hides when the focused base type has narrowing content types', () => { - spectator.component.$selectedBaseTypes.set(['FILEASSET']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[2]]); // videoFile - triggerFocusChange('FILEASSET'); - - expect(spectator.component['$showAllBanner']()).toBe(false); - }); - - it('disappears when clicking the active checkbox (clears the base type)', () => { - // Reaching "all" mode and then clicking the now-checked checkbox - // clears the base type — banner goes away alongside it. - spectator.component.$selectedBaseTypes.set(['FILEASSET']); - triggerFocusChange('FILEASSET'); - expect(spectator.component['$showAllBanner']()).toBe(true); - - triggerBaseTypeToggle('FILEASSET'); - - expect(spectator.component['$showAllBanner']()).toBe(false); - }); - }); - - describe('Filter input', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); - }); - - it('debounces filter changes and calls the service with the latest value', () => { - jest.clearAllMocks(); - triggerSearchInput('b'); - triggerSearchInput('bl'); - triggerSearchInput('blog'); - jest.advanceTimersByTime(600); - - const calls = contentTypeService.getContentTypesWithPagination.mock.calls; - const last = calls[calls.length - 1]?.[0] as { filter?: string }; - expect(last?.filter).toBe('blog'); - }); - - it('resets the filter when the popover hides', () => { - patchState(spectator.component.$state, { contentTypeFilter: 'blog' }); - triggerPanelHide(); - expect(spectator.component.$state.contentTypeFilter()).toBe(''); - }); - - it('handles fetch errors gracefully', () => { - contentTypeService.getContentTypesWithPagination.mockReturnValue( - throwError(() => new Error('boom')) - ); - jest.clearAllMocks(); - triggerSearchInput('blog'); - jest.advanceTimersByTime(600); - - expect(spectator.component.$state.contentTypes()).toEqual([]); - expect(spectator.component.$state.loading()).toBe(false); - }); - }); - describe('Lazy load', () => { - beforeEach(() => { - spectator.detectChanges(); - openPopover(); + expect(store.patchFilters).toHaveBeenCalledWith({ baseType: ['1', '4'] }); }); - it('loads the next page based on the last visible index', () => { - patchState(spectator.component.$state, { - contentTypes: CONTENT_TYPES.slice(0, 5), - currentPage: 1, - canLoadMore: true, - loading: false + it('should patch the content-type variables as-is', () => { + spectator.triggerEventHandler(contentTypeFilter(), 'selectionChange', { + baseTypes: ['CONTENT'], + contentTypes: ['blog'] }); - const next = [ - { - id: '8', - name: 'Extra', - variable: 'extra', - baseType: 'CONTENT', - system: false - } as DotCMSContentType - ]; - contentTypeService.getContentTypesWithPagination.mockReturnValue( - of({ - contentTypes: next, - pagination: { currentPage: 2, totalEntries: 6, totalPages: 2 } as never - }) - ); - - triggerLazyLoad({ first: 0, last: 10 }); - - expect(contentTypeService.getContentTypesWithPagination).toHaveBeenCalledWith( - expect.objectContaining({ page: 2, per_page: 10 }) - ); - expect(spectator.component.$state.contentTypes()).toHaveLength(6); - expect(spectator.component.$state.currentPage()).toBe(2); - }); - - it('does not load when canLoadMore is false', () => { - patchState(spectator.component.$state, { canLoadMore: false }); - jest.clearAllMocks(); - triggerLazyLoad({ first: 0, last: 40 }); - expect(contentTypeService.getContentTypesWithPagination).not.toHaveBeenCalled(); - }); - }); - - describe('Chip integration', () => { - beforeEach(() => spectator.detectChanges()); - it('renders the chip with the configured title', () => { - const chip = spectator.query(byTestId('content-type-filter-chip')); - expect(chip?.querySelector('[data-testid="chip-title"]')?.textContent?.trim()).toBe( - 'Content Types' - ); + expect(store.patchFilters).toHaveBeenCalledWith({ contentType: ['blog'] }); }); - it('toggles the popover when the chip is clicked', () => { - 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')); + it('should remove both filters when the selection is empty', () => { + spectator.triggerEventHandler(contentTypeFilter(), 'selectionChange', { + baseTypes: [], + contentTypes: [] + }); - expect(toggleSpy).toHaveBeenCalled(); + expect(store.removeFilter).toHaveBeenCalledWith('baseType'); + expect(store.removeFilter).toHaveBeenCalledWith('contentType'); }); - it('clears all selections and store when the chip emits removed', () => { - spectator.component.$selectedBaseTypes.set(['CONTENT']); - spectator.component.$selectedContentTypes.set([CONTENT_TYPES[0]]); - spectator.detectChanges(); - - triggerChipRemoved(); + it('should remove only the content-type filter when base types remain selected', () => { + spectator.triggerEventHandler(contentTypeFilter(), 'selectionChange', { + baseTypes: ['CONTENT'], + contentTypes: [] + }); - expect(spectator.component.$selectedBaseTypes()).toEqual([]); - expect(spectator.component.$selectedContentTypes()).toEqual([]); - expect(store.removeFilter).toHaveBeenCalledWith('baseType'); + expect(store.patchFilters).toHaveBeenCalledWith({ baseType: ['1'] }); expect(store.removeFilter).toHaveBeenCalledWith('contentType'); + expect(store.removeFilter).not.toHaveBeenCalledWith('baseType'); }); - }); - describe('Listbox configuration', () => { - it('configures the content-type listbox for multi-select with checkbox + lazy load', () => { - spectator.detectChanges(); - openPopover(); - - const right = rightListbox().componentInstance as Listbox; + it('should drop base types that have no numeric key', () => { + spectator.triggerEventHandler(contentTypeFilter(), 'selectionChange', { + baseTypes: ['CONTENT', 'NOT_A_BASE_TYPE'], + contentTypes: [] + }); - expect(right.multiple).toBe(true); - expect(right.checkbox).toBe(true); - expect(right.lazy).toBe(true); - expect(right.virtualScroll).toBe(true); + expect(store.patchFilters).toHaveBeenCalledWith({ baseType: ['1'] }); }); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.ts index 1f1a8111ab05..f74b945d642a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.ts @@ -1,610 +1,55 @@ -import { patchState, signalState } from '@ngrx/signals'; -import { EMPTY, of, Subject } from 'rxjs'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - OnInit, - computed, - inject, - linkedSignal, - signal -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormsModule } from '@angular/forms'; +import { DotCMSBaseTypesContentTypes } from '@dotcms/dotcms-models'; +import { DotContentTypeFilterComponent, DotContentTypeFilterSelection } from '@dotcms/ui'; -import { CheckboxModule } from 'primeng/checkbox'; -import { IconFieldModule } from 'primeng/iconfield'; -import { InputIconModule } from 'primeng/inputicon'; -import { InputTextModule } from 'primeng/inputtext'; -import { ListboxModule } from 'primeng/listbox'; -import { PopoverModule } from 'primeng/popover'; -import { ScrollerLazyLoadEvent } from 'primeng/scroller'; - -import { catchError, debounceTime, map, switchMap, take, takeUntil, tap } from 'rxjs/operators'; - -import { DotContentTypeService, DotMessageService } from '@dotcms/data-access'; -import { - DotCMSBaseTypesContentTypes, - DotCMSContentType, - DotPagination, - StructureTypeView -} from '@dotcms/dotcms-models'; -import { - CHIP_FILTER_LISTBOX_PT, - CHIP_FILTER_POPOVER_PT, - DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { - DEBOUNCE_TIME, - MAP_BASE_TYPES_TO_NUMBERS, - MAP_NUMBERS_TO_BASE_TYPES -} from '../../../../shared/constants'; +import { MAP_BASE_TYPES_TO_NUMBERS, MAP_NUMBERS_TO_BASE_TYPES } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; -const ALL_CONTENT = '__ALL_CONTENT__'; -const ITEMS_PER_PAGE = 10; - -/** - * Row height (px) used by the right column's virtual scroller. - * Empirically measured against PrimeNG v21 listbox option default styling - * (`--p-listbox-option-padding: 0 1rem` from CHIP_FILTER_LISTBOX_PT, plus the - * `dot-filter-list-item` `py-3` host class). If a future PrimeNG / theme - * upgrade changes the option padding or font, this number needs to be - * re-measured or the scroller will misalign. - */ -const LISTBOX_ITEM_HEIGHT = 40.6; -/** Left listbox viewport height — fits all 9 base-type rows (incl. ALL_CONTENT). */ -const LISTBOX_SCROLL_HEIGHT = `${9 * LISTBOX_ITEM_HEIGHT + 14}px`; -/** Approximate column header height (px-4 py-3 with text-xs uppercase). */ -const POPOVER_HEADER_HEIGHT = '3rem'; /** - * Popover height is derived from the LEFT listbox so the popover always - * matches the natural height of the base-type list — no leftover space at - * the bottom and no clipping when the catalog grows. + * Store adapter over the shared {@link DotContentTypeFilterComponent}. + * + * Its only job is translating between the two representations: the store persists base types as + * the numeric keys the drive API and the URL use, while the shared filter speaks base-type names. */ -const POPOVER_MAX_HEIGHT = `calc(${LISTBOX_SCROLL_HEIGHT} + ${POPOVER_HEADER_HEIGHT})`; - -interface BaseTypeOption { - name: string; - label: string; -} - -interface State { - baseTypes: BaseTypeOption[]; - contentTypes: DotCMSContentType[]; - contentTypeFilter: string; - loading: boolean; - canLoadMore: boolean; - currentPage: number; -} - @Component({ selector: 'dot-content-drive-content-type-filter', - imports: [ - FormsModule, - CheckboxModule, - IconFieldModule, - InputIconModule, - InputTextModule, - ListboxModule, - PopoverModule, - DotChipFilterComponent, - DotFilterListItemComponent, - DotMessagePipe - ], - templateUrl: './dot-content-drive-content-type-filter.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DotContentTypeFilterComponent] }) -export class DotContentDriveContentTypeFilterComponent implements OnInit { +export class DotContentDriveContentTypeFilterComponent { readonly #store = inject(DotContentDriveStore); - readonly #destroyRef = inject(DestroyRef); - readonly #contentTypesService = inject(DotContentTypeService); - readonly #dotMessageService = inject(DotMessageService); - readonly #fetchSubject = new Subject<{ baseType?: string; filter: string }>(); - /** - * Fires whenever the focused base type changes, cancelling any in-flight - * focus or lazy-load fetch so a late response from a previous focus can't - * overwrite the current state. - */ - readonly #cancelFetch$ = new Subject(); - - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; - /** - * PT applied to the base-type checkbox when it's in the indeterminate - * (partial) state — paints the box with the checked-state tokens so the - * pi-minus icon renders white on the primary background. PrimeNG v21 has - * no built-in indeterminate token / class to target, so we override the - * inner box + icon directly via passthrough. - */ - protected readonly partialCheckboxPt = { - box: { - style: { - background: 'var(--p-checkbox-checked-background)', - borderColor: 'var(--p-checkbox-checked-border-color)' - } - }, - icon: { - style: { color: 'var(--p-checkbox-icon-checked-color)' } - } - }; - protected readonly ALL_CONTENT = ALL_CONTENT; - protected readonly ITEMS_PER_PAGE = ITEMS_PER_PAGE; - protected readonly LISTBOX_ITEM_HEIGHT = LISTBOX_ITEM_HEIGHT; - protected readonly POPOVER_MAX_HEIGHT = POPOVER_MAX_HEIGHT; - - readonly $state = signalState({ - baseTypes: [], - contentTypes: [], - contentTypeFilter: '', - loading: true, - canLoadMore: true, - currentPage: 1 - }); - /** - * Cache of every content type ever fetched. Grows monotonically so selected - * items remain resolvable even when they are no longer in the visible page. - */ - readonly #contentTypeCache = signal([]); - - /** Base type whose content types are shown in the right column. ALL_CONTENT shows everything. */ - readonly $focusedBaseType = signal(ALL_CONTENT); - - /** - * Mounted only while the popover is open. Forces the inner listboxes to be - * recreated on each open so virtual scroll measures the correct dimensions - * (otherwise it computes 0 visible items while the overlay is hidden). - */ - readonly $popoverOpen = signal(false); - - /** Selected base types (variable names like 'CONTENT', 'FILEASSET'). */ - readonly $selectedBaseTypes = linkedSignal(() => { + protected readonly $selectedBaseTypes = computed(() => { const keys = (this.#store.getFilterValue('baseType') as string[]) ?? []; - return keys.map((k) => MAP_NUMBERS_TO_BASE_TYPES[Number(k)]).filter(Boolean); - }); - /** - * Selected content types. Derived from the store + cache so selections - * persist across focus changes — the cache holds every content type we - * have ever loaded. - */ - readonly $selectedContentTypes = linkedSignal(() => { - const variables = (this.#store.getFilterValue('contentType') as string[]) ?? []; - if (!variables.length) return []; - const cache = this.#contentTypeCache(); - return cache.filter((ct) => variables.includes(ct.variable)); + return keys.map((key) => MAP_NUMBERS_TO_BASE_TYPES[Number(key)]).filter(Boolean); }); - /** Left column options: ALL_CONTENT prepended to base types. */ - protected readonly $leftOptions = computed(() => [ - { - name: ALL_CONTENT, - label: this.#dotMessageService.get('content-drive.type-filter.all-content-types') - }, - ...this.$state.baseTypes() - ]); - - protected readonly LISTBOX_SCROLL_HEIGHT = LISTBOX_SCROLL_HEIGHT; - /** Banner height matches a single listbox item slot for visual consistency. */ - protected readonly LISTBOX_BANNER_HEIGHT_PX = `${LISTBOX_ITEM_HEIGHT}px`; - - /** Lookup: base type name → human label, used for chip rendering. */ - readonly #baseTypeLabelByName = computed(() => { - const map = new Map(); - for (const bt of this.$state.baseTypes()) map.set(bt.name, bt.label); - return map; - }); - - /** - * Chip selections, formatted per ticket rules. Falls back to the raw - * enum name (e.g. `CONTENT (All)`) if the base-type catalog hasn't loaded - * yet or its API call failed — better to show the active filter with an - * unfriendly label than to hide it entirely and leave the user wondering - * why content is filtered. - */ - readonly $chipSelections = computed(() => { - const baseTypes = this.$selectedBaseTypes(); - if (!baseTypes.length) return []; - - const labels = this.#baseTypeLabelByName(); - const contentTypes = this.$selectedContentTypes(); - const allSuffix = ` (${this.#dotMessageService.get('content-drive.type-filter.all')})`; - - return baseTypes.flatMap((baseType) => { - const narrowed = contentTypes.filter((ct) => ct.baseType === baseType); - if (narrowed.length) return narrowed.map((ct) => ct.name); - return [`${labels.get(baseType) ?? baseType}${allSuffix}`]; - }); - }); - - /** - * Banner above the right list. Shown when the focused base type is selected - * with no content types narrowing it — i.e. the filter is "all of this base". - */ - protected readonly $showAllBanner = computed(() => { - const focused = this.$focusedBaseType(); - if (focused === ALL_CONTENT) return false; - if (!this.$selectedBaseTypes().includes(focused)) return false; - return !this.$selectedContentTypes().some((ct) => ct.baseType === focused); - }); - - /** - * Right listbox shrinks by one item slot when the "all content types" - * banner is visible, so the popover height stays constant — the banner - * takes over the bottom row's space instead of growing the popover. - */ - protected readonly $rightScrollHeight = computed(() => { - const items = this.$showAllBanner() ? 7 : 8; - return `${items * LISTBOX_ITEM_HEIGHT + 14}px`; - }); - - ngOnInit() { - this.#loadBaseTypes(); - this.#loadInitialContentTypes(); - this.#setupFilterSubscription(); - } - - protected isBaseTypeSelected(name: string): boolean { - return this.$selectedBaseTypes().includes(name); - } - - protected onFocusChange(value: string | null): void { - const focused = value ?? ALL_CONTENT; - if (focused === this.$focusedBaseType()) return; - // Cancel any in-flight focus/lazy fetch from the previous focus so a - // late response can't overwrite the new state. - this.#cancelFetch$.next(); - this.$focusedBaseType.set(focused); - // Eagerly clear the right column so stale items from the previous focus - // don't linger while the new fetch is in flight. - patchState(this.$state, { - contentTypes: [], - contentTypeFilter: '', - currentPage: 1, - canLoadMore: true, - loading: true - }); - // Focus changes refetch immediately — no debounce, no race with typing. - this.#loadContentTypes({ - page: 1, - filter: '', - type: focused === ALL_CONTENT ? undefined : focused - }) - .pipe(takeUntil(this.#cancelFetch$)) - .subscribe(({ contentTypes, pagination }) => { - patchState(this.$state, { - contentTypes, - loading: false, - canLoadMore: this.#hasMorePages(pagination), - currentPage: pagination.currentPage - }); - this.#cacheContentTypes(contentTypes); - }); - } - - /** - * Two-state toggle from the left listbox checkbox: - * - unchecked → select the base type (no content types added). - * - any active state (fully checked OR indeterminate/partial) → drop the - * base AND its content types. One coherent rule: clicking an active - * checkbox clears it. - * - * Promoting a partial selection to "all of this base type" is reachable - * via two clicks (partial → empty → checked). Making the partial click do - * that promotion fights the standard "indeterminate checkbox click clears - * the selection" expectation, which was confusing in user testing. - * - * The `checked` value emitted by p-checkbox is ignored on purpose; we - * compute the next state from the current selection. - */ - protected onBaseTypeToggle(name: string): void { - const isSelected = this.$selectedBaseTypes().includes(name); - - if (isSelected) { - this.$selectedBaseTypes.update((list) => list.filter((n) => n !== name)); - this.$selectedContentTypes.update((list) => - (list ?? []).filter((ct) => ct.baseType !== name) - ); - // Unchecking the base type you're viewing resets the right column - // to "all content types" — the natural no-filter view. Unchecking a - // base type you're NOT viewing leaves the right column untouched. - if (this.$focusedBaseType() === name) { - this.onFocusChange(ALL_CONTENT); - } - } else { - this.$selectedBaseTypes.update((list) => [...list, name]); - // Checking a base type focuses it, so its content types load on the - // right — keeps the checkbox click consistent with a title click. - this.onFocusChange(name); - } - this.#syncStore(); - } - - /** - * `true` when the base type has narrowing content types selected — drives - * the indeterminate (`pi-minus`) state on its checkbox. - */ - protected isBaseTypePartial(name: string): boolean { - if (!this.$selectedBaseTypes().includes(name)) return false; - return this.$selectedContentTypes().some((ct) => ct.baseType === name); - } - - /** - * Reconciles base-type selection after the user toggled content types. - * Cascades up (selecting a content type adds its base type) and cascades - * down (when a base type loses its last selected content type, the base - * type itself is dropped from the selection). - */ - protected onContentTypeChange(newValue: DotCMSContentType[] | null): void { - const previous = this.$selectedContentTypes() ?? []; - const next = newValue ?? []; - - const previousBasesWithCts = new Set(previous.map((ct) => ct.baseType)); - const nextBasesWithCts = new Set(next.map((ct) => ct.baseType)); - - // Bases that lost their last selected content type in this change. - const droppedBases = [...previousBasesWithCts].filter((bt) => !nextBasesWithCts.has(bt)); - - this.$selectedContentTypes.set(next); - - const baseTypes = new Set(this.$selectedBaseTypes()); - for (const bt of nextBasesWithCts) baseTypes.add(bt); // cascade up - for (const bt of droppedBases) baseTypes.delete(bt); // cascade down - this.$selectedBaseTypes.set([...baseTypes]); - - this.#syncStore(); - } - - protected onSearchInput(value: string): void { - const filter = value ?? ''; - patchState(this.$state, { contentTypeFilter: filter }); - const focused = this.$focusedBaseType(); - this.#fetchSubject.next({ - baseType: focused === ALL_CONTENT ? undefined : focused, - filter - }); - } - - protected onPanelHide(): void { - this.$popoverOpen.set(false); - patchState(this.$state, { contentTypeFilter: '' }); - // $focusedBaseType is intentionally NOT reset — the user's last focus - // persists across popover sessions so reopening lands them where they - // left off. The @if ($popoverOpen()) recreate trick re-triggers the - // listbox's lazy load on next open, so the data is still fresh. - } - - protected onLazyLoad(event: ScrollerLazyLoadEvent): void { - const last = typeof event.last === 'number' ? event.last : NaN; - if (!Number.isFinite(last)) return; - // PrimeNG's virtual scroller emits `last` as the last visible row index; - // `Math.ceil(last / ITEMS_PER_PAGE) + 1` resolves to the *next* page, - // which means we prefetch page N+1 as soon as the user reaches any - // visible item on page N. Intentional: keeps scrolling smooth. - const page = Math.ceil(last / ITEMS_PER_PAGE) + 1; - if (!this.$state.canLoadMore() || page <= this.$state.currentPage()) return; - - patchState(this.$state, { currentPage: page }); - const focused = this.$focusedBaseType(); - this.#loadContentTypes({ - page, - filter: this.$state.contentTypeFilter(), - type: focused === ALL_CONTENT ? undefined : focused - }) - // Cancel if the user changes focus mid-flight; the new fetch will - // own the right list. - .pipe(takeUntil(this.#cancelFetch$)) - .subscribe(({ contentTypes, pagination }) => { - if (!contentTypes.length) { - patchState(this.$state, { canLoadMore: false, loading: false }); - return; - } - const merged = [...this.$state.contentTypes(), ...contentTypes]; - patchState(this.$state, { - contentTypes: merged, - canLoadMore: this.#hasMorePages(pagination), - loading: false, - currentPage: - pagination.currentPage > this.$state.currentPage() - ? pagination.currentPage - : this.$state.currentPage() - }); - this.#cacheContentTypes(contentTypes); - }); - } - - protected onClearAll(): void { - this.$selectedBaseTypes.set([]); - this.$selectedContentTypes.set([]); - this.#syncStore(); - } - - #syncStore(): void { - const baseTypes = this.$selectedBaseTypes(); - const contentTypes = this.$selectedContentTypes() ?? []; + protected readonly $selectedContentTypes = computed( + () => (this.#store.getFilterValue('contentType') as string[]) ?? [] + ); + protected onSelectionChange({ baseTypes, contentTypes }: DotContentTypeFilterSelection): void { if (baseTypes.length) { const keys = baseTypes .map((name) => MAP_BASE_TYPES_TO_NUMBERS[name as DotCMSBaseTypesContentTypes]) - .filter((k): k is string => !!k); + .filter((key): key is string => !!key); this.#store.patchFilters({ baseType: keys }); } else { this.#store.removeFilter('baseType'); } if (contentTypes.length) { - this.#store.patchFilters({ - contentType: contentTypes.map((ct) => ct.variable) - }); + this.#store.patchFilters({ contentType: contentTypes }); } else { this.#store.removeFilter('contentType'); } } - - #loadBaseTypes(): void { - this.#contentTypesService - .getAllContentTypes() - .pipe( - take(1), - map((response: StructureTypeView[]) => - response.filter((item) => item.name !== DotCMSBaseTypesContentTypes.FORM) - ), - catchError(() => of([] as StructureTypeView[])) - ) - .subscribe((response) => { - patchState(this.$state, { - baseTypes: response.map(({ name, label }) => ({ name, label })) - }); - }); - } - - #loadInitialContentTypes(): void { - // The cache is empty at this point, so #ensureParam would return - // `undefined` even when the store has selected content types from a - // restored URL. Read the variables straight from the store so the - // first fetch can ensure those items appear on page 1 (and seed the - // cache for $selectedContentTypes to resolve them). - const variables = (this.#store.getFilterValue('contentType') as string[]) ?? []; - const ensure = variables.length ? variables.join(',') : undefined; - // `loading` is already true from initial state; no pre-fetch tap needed. - this.#contentTypesService - .getContentTypesWithPagination({ - ensure, - per_page: ITEMS_PER_PAGE - }) - .pipe( - catchError(() => - of({ - contentTypes: [], - pagination: { currentPage: 1, totalEntries: 0 } as DotPagination - }) - ), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe(({ contentTypes, pagination }) => { - const filtered = this.#filterContentTypes(contentTypes); - patchState(this.$state, { - contentTypes: filtered, - canLoadMore: this.#hasMorePages(pagination), - loading: false, - currentPage: pagination.currentPage - }); - // Cache the raw response — `ensure`-restored content types may be - // system or FORM (filtered out of the visible options) but they - // still need to resolve in $selectedContentTypes so #syncStore - // doesn't drop a URL-restored filter on first user interaction. - this.#cacheContentTypes(contentTypes); - }); - } - - #setupFilterSubscription(): void { - this.#fetchSubject - .pipe( - tap(() => patchState(this.$state, { loading: true })), - debounceTime(DEBOUNCE_TIME), - switchMap((req) => { - // If focus changed during the debounce window, the - // focus-change path already kicked off its own fetch and - // owns the right list — drop this stale buffered search - // so it can't race in and overwrite the new state. - const focused = this.$focusedBaseType(); - const currentType = focused === ALL_CONTENT ? undefined : focused; - if (req.baseType !== currentType) { - patchState(this.$state, { loading: false }); - return EMPTY; - } - return this.#loadContentTypes({ - page: 1, - filter: req.filter, - type: req.baseType - }).pipe(takeUntil(this.#cancelFetch$)); - }), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe(({ contentTypes, pagination }) => { - patchState(this.$state, { - contentTypes, - loading: false, - canLoadMore: this.#hasMorePages(pagination), - currentPage: pagination.currentPage - }); - this.#cacheContentTypes(contentTypes); - }); - } - - #loadContentTypes({ page, filter, type }: { page: number; filter: string; type?: string }) { - return this.#contentTypesService - .getContentTypesWithPagination({ - filter, - type, - ensure: this.#ensureParam(), - page, - per_page: ITEMS_PER_PAGE - }) - .pipe( - take(1), - takeUntilDestroyed(this.#destroyRef), - catchError(() => - of({ - contentTypes: [], - pagination: { currentPage: 1, totalEntries: 0 } as DotPagination - }) - ), - map(({ contentTypes, pagination }) => ({ - contentTypes: this.#filterContentTypes(contentTypes), - pagination - })) - ); - } - - #filterContentTypes(contentTypes: DotCMSContentType[]): DotCMSContentType[] { - return contentTypes.filter( - (ct) => !ct.system && ct.baseType !== DotCMSBaseTypesContentTypes.FORM - ); - } - - /** - * Source-of-truth for "is there another page to load?". Computes total - * pages from the server's `totalEntries` (which counts every content type, - * including the FORM / system items we strip client-side). In the worst - * case this triggers ONE extra empty fetch — when the final page contains - * only filtered-out items — which the `if (!contentTypes.length)` guard - * in `onLazyLoad` catches by setting `canLoadMore: false`. We accept that - * trade-off rather than tracking a separate "filtered total" client-side. - */ - #hasMorePages(pagination: DotPagination): boolean { - const perPage = pagination.perPage || ITEMS_PER_PAGE; - const totalPages = Math.ceil((pagination.totalEntries ?? 0) / perPage); - return pagination.currentPage < totalPages; - } - - #cacheContentTypes(contentTypes: DotCMSContentType[]): void { - if (!contentTypes.length) return; - this.#contentTypeCache.update((cache) => { - const seen = new Set(cache.map((ct) => ct.variable)); - const additions = contentTypes.filter((ct) => !seen.has(ct.variable)); - return additions.length ? [...cache, ...additions] : cache; - }); - } - - /** - * Only ensure selected content types that actually belong to the focused - * base type. Otherwise the server would be told to include items that the - * current focus would never legitimately return (e.g. a CONTENT-typed - * selection while focusing FILEASSET). - */ - #ensureParam(): string | undefined { - const focused = this.$focusedBaseType(); - const selected = this.$selectedContentTypes() ?? []; - const relevant = - focused === ALL_CONTENT ? selected : selected.filter((ct) => ct.baseType === focused); - - return relevant.length ? relevant.map((ct) => ct.variable).join(',') : undefined; - } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.ts index ad65b0e7c9ec..fa2d802c9727 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.ts @@ -24,9 +24,9 @@ import { DotCMSContentType, DotCMSContentTypeField } from '@dotcms/dotcms-models import { CHIP_FILTER_LISTBOX_PT, CHIP_FILTER_POPOVER_PT, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; + DotFilterListItemComponent, + DotMessagePipe +} from '@dotcms/ui'; import { TITLE_FIELD_VARIABLE, USER_SEARCHABLE_FIELD_TYPES } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.ts index 3d37c1846350..a2ef1a1264ac 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.ts @@ -40,9 +40,9 @@ import { CHIP_FILTER_LISTBOX_PT, CHIP_FILTER_POPOVER_PT, DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; + DotFilterListItemComponent, + DotMessagePipe +} from '@dotcms/ui'; import { DotContentDriveRelationshipFooterComponent } from './dot-content-drive-relationship-footer/dot-content-drive-relationship-footer.component'; 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.ts b/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.ts index 1fa30ffad124..d203ebaa610a 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.ts +++ b/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.ts @@ -1,97 +1,35 @@ -import { patchState, signalState } from '@ngrx/signals'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { - ChangeDetectionStrategy, - Component, - OnInit, - computed, - inject, - linkedSignal -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { DotLanguageFilterComponent } from '@dotcms/ui'; -import { ListboxModule } from 'primeng/listbox'; -import { PopoverModule } from 'primeng/popover'; - -import { DotLanguagesService } from '@dotcms/data-access'; -import { DotLanguage } from '@dotcms/dotcms-models'; -import { - CHIP_FILTER_LISTBOX_PT, - CHIP_FILTER_POPOVER_PT, - DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; +/** + * Store adapter over the shared {@link DotLanguageFilterComponent}. The store persists language ids + * as strings (they travel through the URL); the shared filter works with numbers. + */ @Component({ selector: 'dot-content-drive-language-field', - imports: [ - FormsModule, - ListboxModule, - PopoverModule, - DotChipFilterComponent, - DotFilterListItemComponent, - DotMessagePipe - ], - templateUrl: './dot-content-drive-language-field.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DotLanguageFilterComponent] }) -export class DotContentDriveLanguageFieldComponent implements OnInit { - readonly #dotLanguagesService = inject(DotLanguagesService); +export class DotContentDriveLanguageFieldComponent { readonly #store = inject(DotContentDriveStore); - $selectedLanguages = linkedSignal(() => { - const languageIds = this.#store.getFilterValue('languageId') as string[]; - - if (!languageIds) { - return []; - } - - return languageIds.map((language) => Number(language)); - }); - - readonly $state = signalState<{ languages: DotLanguage[] }>({ - languages: [] - }); - - protected readonly LISTBOX_SCROLL_HEIGHT = PANEL_SCROLL_HEIGHT; - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; + protected readonly $selectedLanguageIds = computed(() => + ((this.#store.getFilterValue('languageId') as string[]) ?? []).map(Number) + ); - 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() { - const value = this.$selectedLanguages() ?? []; - if (value.length > 0) { - this.#store.patchFilters({ - languageId: value.map((language) => language.toString()) - }); + protected onSelectionChange(languageIds: number[]): void { + if (languageIds.length) { + this.#store.patchFilters({ languageId: languageIds.map(String) }); } else { this.#store.removeFilter('languageId'); } } - - onRemoveAll() { - this.$selectedLanguages.set([]); - this.onChange(); - } } 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.spec.ts b/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.spec.ts index 627e49f917bb..95411744046f 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.spec.ts +++ b/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.spec.ts @@ -1,193 +1,88 @@ -import { - byTestId, - createComponentFactory, - mockProvider, - Spectator, - SpyObject -} from '@openng/spectator/jest'; +import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@openng/spectator/jest'; import { of } from 'rxjs'; import { By } from '@angular/platform-browser'; -import { Listbox } from 'primeng/listbox'; -import { Popover } from 'primeng/popover'; - import { DotLanguagesService, DotMessageService } from '@dotcms/data-access'; -import { DotLanguage } from '@dotcms/dotcms-models'; -import { DotChipFilterComponent } from '@dotcms/portlets/content-drive/ui'; +import { DotLanguageFilterComponent } from '@dotcms/ui'; import { createFakeLanguage, MockDotMessageService } from '@dotcms/utils-testing'; import { DotContentDriveLanguageFieldComponent } from './dot-content-drive-language-field.component'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; -const MOCK_LANGUAGES: DotLanguage[] = [ - createFakeLanguage({ - id: 1, - languageCode: 'en', - countryCode: 'US', - language: 'English', - country: 'United States', - isoCode: 'en-US' - }), - createFakeLanguage({ - id: 2, - languageCode: 'es', - countryCode: 'ES', - language: 'Spanish', - country: 'Spain', - isoCode: 'es-ES' - }), - createFakeLanguage({ - id: 3, - languageCode: 'fr', - countryCode: 'FR', - language: 'French', - country: 'France', - isoCode: 'fr-FR' - }) -]; - describe('DotContentDriveLanguageFieldComponent', () => { let spectator: Spectator; - let component: DotContentDriveLanguageFieldComponent; let store: SpyObject>; - let languagesService: SpyObject; const createComponent = createComponentFactory({ component: DotContentDriveLanguageFieldComponent, providers: [ mockProvider(DotContentDriveStore, { + getFilterValue: jest.fn().mockReturnValue(undefined), patchFilters: jest.fn(), - removeFilter: jest.fn(), - getFilterValue: jest.fn() + removeFilter: jest.fn() }), mockProvider(DotLanguagesService, { - get: jest.fn().mockReturnValue(of(MOCK_LANGUAGES)) + get: jest.fn().mockReturnValue(of([createFakeLanguage({ id: 1 })])) }), { 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 }); + const languageFilter = () => + spectator.fixture.debugElement.query(By.directive(DotLanguageFilterComponent)); + beforeEach(() => { spectator = createComponent(); - component = spectator.component; store = spectator.inject(DotContentDriveStore, true); - languagesService = spectator.inject(DotLanguagesService); - store.getFilterValue.mockReturnValue([]); + store.getFilterValue.mockReset().mockReturnValue(undefined); }); afterEach(() => jest.clearAllMocks()); - it('should fetch languages and populate state', () => { + it('should render the shared language filter', () => { spectator.detectChanges(); - expect(languagesService.get).toHaveBeenCalled(); - expect(component.$state().languages).toEqual(MOCK_LANGUAGES); + expect(languageFilter()).toBeTruthy(); }); - it('should set selectedLanguages when store has languageId filter', () => { + it('should bind the store languageId filter as numbers', () => { store.getFilterValue.mockReturnValue(['1', '2']); - spectator.detectChanges(); + expect(languageFilter().componentInstance.$selectedLanguageIds()).toEqual([1, 2]); expect(store.getFilterValue).toHaveBeenCalledWith('languageId'); - expect(component.$selectedLanguages()).toEqual([1, 2]); }); - it('should patch filters with string values when selectedLanguages has values', () => { + it('should bind an empty selection when no languageId filter is set', () => { spectator.detectChanges(); - component.$selectedLanguages.set([1, 2]); - component.onChange(); - - expect(store.patchFilters).toHaveBeenCalledWith({ - languageId: ['1', '2'] - }); + expect(languageFilter().componentInstance.$selectedLanguageIds()).toEqual([]); }); - it('should remove filter when selectedLanguages is empty', () => { - store.getFilterValue.mockReturnValue(['1']); + it('should patch the store with string ids when a selection is emitted', () => { spectator.detectChanges(); - component.$selectedLanguages.set([]); - component.onChange(); + spectator.triggerEventHandler(languageFilter(), 'selectionChange', [1, 2]); - expect(store.removeFilter).toHaveBeenCalledWith('languageId'); + expect(store.patchFilters).toHaveBeenCalledWith({ languageId: ['1', '2'] }); }); - describe('Chip', () => { - it('should render the chip with the placeholder as title', () => { - 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', () => { - store.getFilterValue.mockReturnValue(['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 selection and remove filter when the chip emits removed', () => { - store.getFilterValue.mockReturnValue(['1']); - spectator.detectChanges(); - - const chipDe = spectator.fixture.debugElement.query( - By.directive(DotChipFilterComponent) - ); - spectator.triggerEventHandler(chipDe, 'removed', undefined); - - expect(component.$selectedLanguages()).toEqual([]); - expect(store.removeFilter).toHaveBeenCalledWith('languageId'); - }); - }); - - 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(); + it('should remove the filter when an empty selection is emitted', () => { + store.getFilterValue.mockReturnValue(['1']); + spectator.detectChanges(); - const listboxDe = spectator.fixture.debugElement.query(By.directive(Listbox)); - const listbox = listboxDe.componentInstance as Listbox; + spectator.triggerEventHandler(languageFilter(), 'selectionChange', []); - expect(listbox.scrollHeight).toBe('25rem'); - expect(listbox.multiple).toBe(true); - expect(listbox.checkbox).toBe(true); - }); + expect(store.removeFilter).toHaveBeenCalledWith('languageId'); + expect(store.patchFilters).not.toHaveBeenCalled(); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.ts index fae52932ed35..f2c76e21e05c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.ts @@ -22,11 +22,7 @@ import { ScrollerLazyLoadEvent } from 'primeng/scroller'; import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators'; -import { - CHIP_FILTER_LISTBOX_PT, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; +import { CHIP_FILTER_LISTBOX_PT, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { DEBOUNCE_TIME, PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; 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.spec.ts b/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.spec.ts index 26eab93d71fb..33278606b8d5 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.spec.ts +++ b/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.spec.ts @@ -1,19 +1,10 @@ -import { - Spectator, - SpyObject, - byTestId, - createComponentFactory, - mockProvider -} from '@openng/spectator/jest'; +import { createComponentFactory, mockProvider, Spectator, SpyObject } from '@openng/spectator/jest'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; -import { IconFieldModule } from 'primeng/iconfield'; -import { InputIconModule } from 'primeng/inputicon'; -import { InputTextModule } from 'primeng/inputtext'; - -import { ALL_FOLDER } from '@dotcms/portlets/content-drive/ui'; +import { DotMessageService } from '@dotcms/data-access'; +import { ALL_FOLDER, DotSearchInputComponent } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotContentDriveSearchInputComponent } from './dot-content-drive-search-input.component'; @@ -21,172 +12,71 @@ import { DotContentDriveStore } from '../../../../store/dot-content-drive.store' describe('DotContentDriveSearchInputComponent', () => { let spectator: Spectator; - let mockStore: SpyObject>; + let store: SpyObject>; const createComponent = createComponentFactory({ component: DotContentDriveSearchInputComponent, - imports: [ReactiveFormsModule, IconFieldModule, InputIconModule, InputTextModule], providers: [ mockProvider(DotContentDriveStore, { - patchFilters: jest.fn(), - removeFilter: jest.fn(), - getFilterValue: jest.fn(), + getFilterValue: jest.fn().mockReturnValue(undefined), setGlobalSearch: jest.fn(), setSelectedNode: jest.fn() - }) + }), + { + provide: DotMessageService, + useValue: new MockDotMessageService({ search: 'Search' }) + } ], detectChanges: false }); + const searchInput = () => + spectator.fixture.debugElement.query(By.directive(DotSearchInputComponent)); + beforeEach(() => { spectator = createComponent(); - mockStore = spectator.inject(DotContentDriveStore); - mockStore.getFilterValue.mockReturnValue(undefined); - }); - - afterEach(() => { - jest.clearAllMocks(); + store = spectator.inject(DotContentDriveStore, true); + store.getFilterValue.mockReset().mockReturnValue(undefined); }); - describe('Component Initialization', () => { - it('should create successfully', () => { - expect(spectator.component).toBeTruthy(); - }); + afterEach(() => jest.clearAllMocks()); - it('should initialize with empty form control by default', () => { - spectator.detectChanges(); + it('should render the shared search input', () => { + spectator.detectChanges(); - expect(spectator.component.searchControl.value).toBe(''); - }); - - it('should load existing filter value from store on init', () => { - const existingValue = 'existing search term'; - mockStore.getFilterValue.mockReturnValue(existingValue); - - spectator.detectChanges(); - - expect(mockStore.getFilterValue).toHaveBeenCalledWith('title'); - expect(spectator.component.searchControl.value).toBe(existingValue); - }); + expect(searchInput()).toBeTruthy(); }); - describe('Template', () => { - beforeEach(() => { - spectator.detectChanges(); - }); - - it('should render search input element', () => { - const input = spectator.query('input'); - expect(input).toBeTruthy(); - }); + it('should bind the store title filter as the value', () => { + store.getFilterValue.mockReturnValue('blog'); + spectator.detectChanges(); - it('should bind form control to input', () => { - const input = spectator.query('input') as HTMLInputElement; - - spectator.component.searchControl.setValue('test value'); - spectator.detectChanges(); - - expect(input.value).toBe('test value'); - }); + expect(searchInput().componentInstance.$value()).toBe('blog'); + expect(store.getFilterValue).toHaveBeenCalledWith('title'); }); - describe('Global Search Action', () => { - beforeEach(() => { - spectator.detectChanges(); - }); - - it('should call patchFilters after debounce when input has value', fakeAsync(() => { - const input = spectator.query('input') as HTMLInputElement; - - spectator.typeInElement('search term', input); - tick(500); - - expect(mockStore.setGlobalSearch).toHaveBeenCalledWith('search term'); - expect(mockStore.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); - })); - - it('should call removeFilter when input is empty', fakeAsync(() => { - const input = spectator.query('input') as HTMLInputElement; - - spectator.typeInElement(' ', input); - tick(500); + it('should bind an empty value when no title filter is set', () => { + spectator.detectChanges(); - expect(mockStore.setGlobalSearch).toHaveBeenCalledWith(''); - expect(mockStore.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); - })); - - it('should debounce input changes by 500ms', fakeAsync(() => { - const input = spectator.query('input') as HTMLInputElement; - - spectator.typeInElement('test', input); - - expect(mockStore.patchFilters).not.toHaveBeenCalled(); - - tick(499); - expect(mockStore.patchFilters).not.toHaveBeenCalled(); - - tick(1); - expect(mockStore.setGlobalSearch).toHaveBeenCalledWith('test'); - expect(mockStore.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); - })); - - it('should trim whitespace from input values', fakeAsync(() => { - const input = spectator.query('input') as HTMLInputElement; - - spectator.typeInElement(' trimmed value ', input); - tick(500); - - expect(mockStore.setGlobalSearch).toHaveBeenCalledWith('trimmed value'); - expect(mockStore.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); - })); - - it('should handle special characters correctly', fakeAsync(() => { - const input = spectator.query('input') as HTMLInputElement; - const specialChars = 'test-search+term (with) special chars!'; - - spectator.typeInElement(specialChars, input); - tick(500); - - expect(mockStore.setGlobalSearch).toHaveBeenCalledWith(specialChars); - expect(mockStore.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); - })); + expect(searchInput().componentInstance.$value()).toBe(''); }); - describe('OnDestroy', () => { - it('should not call store methods after component is destroyed', fakeAsync(() => { - spectator.detectChanges(); - const input = spectator.query('input') as HTMLInputElement; + it('should push the emitted term to the store and reset the folder scope', () => { + spectator.detectChanges(); - spectator.typeInElement('test', input); - spectator.fixture.destroy(); - tick(500); + spectator.triggerEventHandler(searchInput(), 'search', 'blog'); - expect(mockStore.patchFilters).not.toHaveBeenCalled(); - })); + expect(store.setGlobalSearch).toHaveBeenCalledWith('blog'); + expect(store.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); }); - describe('Clear Icon', () => { - it('should appear when input has value', () => { - mockStore.getFilterValue.mockReturnValue('test value'); - spectator.detectChanges(); - - expect(spectator.query(byTestId('search-icon-clear'))).toBeTruthy(); - }); - - it('should not appear when input is empty', () => { - mockStore.getFilterValue.mockReturnValue(''); - spectator.detectChanges(); - - expect(spectator.query(byTestId('search-icon-clear'))).not.toBeTruthy(); - }); - - it('should clear input when clear icon is clicked', () => { - mockStore.getFilterValue.mockReturnValue('test value'); - spectator.detectChanges(); + it('should clear the search in the store when an empty term is emitted', () => { + store.getFilterValue.mockReturnValue('blog'); + spectator.detectChanges(); - spectator.click(spectator.query(byTestId('search-icon-clear'))); + spectator.triggerEventHandler(searchInput(), 'search', ''); - expect(spectator.component.searchControl.value).toBe(null); - }); + expect(store.setGlobalSearch).toHaveBeenCalledWith(''); + expect(store.setSelectedNode).toHaveBeenCalledWith(ALL_FOLDER); }); }); 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.ts b/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.ts index 0cbff1e973b8..7ff2b915a9dc 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.ts +++ b/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.ts @@ -1,69 +1,37 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - DestroyRef, - effect, - inject, - OnInit -} from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; -import { IconField } from 'primeng/iconfield'; -import { InputIcon } from 'primeng/inputicon'; -import { InputTextModule } from 'primeng/inputtext'; +import { ALL_FOLDER, DotSearchInputComponent } from '@dotcms/ui'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; - -import { ALL_FOLDER } from '@dotcms/portlets/content-drive/ui'; - -import { DEBOUNCE_TIME } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; +/** + * Store adapter over the shared {@link DotSearchInputComponent}: binds the `title` filter in and + * writes the debounced term back. The presentational box (debounce, clear icon) lives in + * `@dotcms/ui` so AssetPicker can reuse it without the store. + */ @Component({ selector: 'dot-content-drive-search-input', - templateUrl: './dot-content-drive-search-input.component.html', + template: ` + + + `, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [IconField, InputIcon, InputTextModule, ReactiveFormsModule], + imports: [DotSearchInputComponent], host: { class: 'w-full' } }) -export class DotContentDriveSearchInputComponent implements OnInit { +export class DotContentDriveSearchInputComponent { readonly #store = inject(DotContentDriveStore); - readonly #destroyRef = inject(DestroyRef); - - readonly searchControl = new FormControl(''); - - readonly cleanTextEffect = effect(() => { - const searchValue = this.#store.getFilterValue('title') || ''; - - if (searchValue !== this.searchControl.value) { - this.searchControl.setValue(searchValue as string, { emitEvent: false }); - } - }); - - readonly $title = computed(() => this.#store.getFilterValue('title') || '', { - equal: (a, b) => a === b - }); - - // We need to use ngOnInit to retrieve the filter value from the store - ngOnInit() { - const searchValue = this.#store.getFilterValue('title'); - - if (searchValue) { - this.searchControl.setValue(searchValue as string); - } - this.searchControl.valueChanges - .pipe( - debounceTime(DEBOUNCE_TIME), - distinctUntilChanged(), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe((value) => { - const searchValue = (value as string)?.trim() || ''; - this.#store.setGlobalSearch(searchValue); - this.#store.setSelectedNode(ALL_FOLDER); - }); + protected readonly $searchTerm = computed( + () => (this.#store.getFilterValue('title') as string) ?? '' + ); + + /** + * A new search resets the folder scope: results are drive-wide, so leaving the tree pinned to + * the previously selected folder would contradict what the list shows. + */ + protected onSearch(term: string): void { + this.#store.setGlobalSearch(term); + this.#store.setSelectedNode(ALL_FOLDER); } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts index 81a5cd6e7796..47b3a003c671 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts @@ -28,9 +28,9 @@ import { CHIP_FILTER_LISTBOX_PT, CHIP_FILTER_POPOVER_PT, DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; + DotFilterListItemComponent, + DotMessagePipe +} from '@dotcms/ui'; import { PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html index c4d1cc4257de..d1b70d896944 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html @@ -11,14 +11,12 @@
@if ($displayButton()) { - + (upload)="$upload.emit($event)" /> ({ alias: 'upload' }); /** - * Upload button label, folder-aware: when the current folder pins uploads to a base type - * (`defaultBaseType`), the button reads "Upload Asset" / "Upload File"; otherwise "Upload". + * Base type the current folder pins uploads to, if any. `dot-upload-button` turns it into the + * folder-aware label ("Upload Asset" / "Upload File" / "Upload"). */ - protected readonly $uploadLabelKey = computed(() => { + protected readonly $uploadBaseType = computed(() => { const data = this.#store.selectedNode()?.data; - const defaultBaseType = - data && data.type !== LOAD_MORE_NODE_TYPE - ? (data as DotFolderTreeNodeContentData).defaultBaseType - : undefined; - switch (defaultBaseType?.toUpperCase()) { - case DotCMSBaseTypesContentTypes.DOTASSET: - return 'content-drive.upload-asset'; - case DotCMSBaseTypesContentTypes.FILEASSET: - return 'content-drive.upload-file'; - default: - return 'content-drive.upload'; - } + + return data && data.type !== LOAD_MORE_NODE_TYPE + ? ((data as DotFolderTreeNodeContentData).defaultBaseType ?? null) + : null; }); readonly $items = signal([ diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index acfbdee80491..d55e2367e2f5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -29,9 +29,11 @@ (moveItems)="onMoveItems($event)" />
- - + @@ -149,7 +151,7 @@ into whichever presentation is active: a popover for the Upload button, a modal for drag-drop. --> @if ($uploadSelectorPayload(); as payload) { - { it('should open the upload menu with the selected folder when the upload button is clicked', () => { openViaButton(TARGET_FOLDER_DATA); - const selector = spectator.query(DotContentDriveDialogUploadSelectorComponent); + const selector = spectator.query(DotUploadTypeSelectorComponent); expect(selector).toBeTruthy(); expect(selector.$targetFolder()).toEqual(TARGET_FOLDER_DATA); expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled(); @@ -1078,7 +1077,7 @@ describe('DotContentDriveShellComponent', () => { }); spectator.detectChanges(); - const selector = spectator.query(DotContentDriveDialogUploadSelectorComponent); + const selector = spectator.query(DotUploadTypeSelectorComponent); expect(selector).toBeTruthy(); expect(selector.$files()).toBe(files); expect(selector.$targetFolder()).toEqual(TARGET_FOLDER_DATA); @@ -1095,7 +1094,7 @@ describe('DotContentDriveShellComponent', () => { }); spectator.detectChanges(); - const selector = spectator.query(DotContentDriveDialogUploadSelectorComponent); + const selector = spectator.query(DotUploadTypeSelectorComponent); expect(selector).toBeTruthy(); expect(selector.$files()).toBe(files); expect(uploadService.uploadFileByBaseType).not.toHaveBeenCalled(); @@ -1115,7 +1114,7 @@ describe('DotContentDriveShellComponent', () => { it('should clear the selector payload when the popover is dismissed without a selection', () => { openViaButton(TARGET_FOLDER_DATA); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeTruthy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeTruthy(); const popover = spectator.debugElement.query( By.css('[data-testId="upload-selector-popover"]') @@ -1123,7 +1122,7 @@ describe('DotContentDriveShellComponent', () => { spectator.triggerEventHandler(popover, 'onHide', {}); spectator.detectChanges(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeFalsy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeFalsy(); }); const dropFiles = () => @@ -1169,7 +1168,7 @@ describe('DotContentDriveShellComponent', () => { spectator.detectChanges(); expect(spectator.component.$uploadSelectorPayload()).toBeTruthy(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeTruthy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeTruthy(); }); }); @@ -1450,7 +1449,7 @@ describe('DotContentDriveShellComponent', () => { spectator.detectChanges(); expect(clickSpy).toHaveBeenCalled(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeFalsy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeFalsy(); }); it('should upload with the folder base type after the picker returns (button flow)', () => { @@ -1493,7 +1492,7 @@ describe('DotContentDriveShellComponent', () => { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeFalsy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeFalsy(); }); }); @@ -2443,7 +2442,7 @@ describe('DotContentDriveShellComponent', () => { }); spectator.detectChanges(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeTruthy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeTruthy(); expect(clickSpy).not.toHaveBeenCalled(); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index b0b073643976..d8e7fa7d9565 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -46,23 +46,27 @@ import { } from '@dotcms/dotcms-models'; import { DotEditContentSidePanelComponent, DotSidePanelNavController } from '@dotcms/edit-content'; import { - DotFolderListViewComponent, - DOT_FOLDER_LIST_VIEW_COLUMN_TYPE, DotContentDriveUploadFiles, - DotFolderListViewColumn, DotFolderTreeNodeData, DotFolderTreeNodeContentData, DotContentDriveMoveItems, LOAD_MORE_NODE_TYPE } from '@dotcms/portlets/content-drive/ui'; import { DotUVEPaletteListTypes } from '@dotcms/portlets/dot-ema/ui'; -import { DotAddToBundleComponent, DotMessagePipe, DotSeverityIconComponent } from '@dotcms/ui'; +import { + DotAddToBundleComponent, + DotFolderListViewComponent, + DOT_FOLDER_LIST_VIEW_COLUMN_TYPE, + DotFolderListViewColumn, + DotMessagePipe, + DotSeverityIconComponent, + DotUploadDropzoneComponent, + DotUploadTypeSelectorComponent +} from '@dotcms/ui'; import { DotContentDriveActionCenterComponent } from '../components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component'; import { DotContentDriveDialogContentTypeSelectorComponent } from '../components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component'; import { DotContentDriveDialogFolderComponent } from '../components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component'; -import { DotContentDriveDialogUploadSelectorComponent } from '../components/dialogs/dot-content-drive-dialog-upload-selector/dot-content-drive-dialog-upload-selector.component'; -import { DotContentDriveDropzoneComponent } from '../components/dot-content-drive-dropzone/dot-content-drive-dropzone.component'; import { DotContentDriveSidebarComponent } from '../components/dot-content-drive-sidebar/dot-content-drive-sidebar.component'; import { DotContentDriveToolbarComponent } from '../components/dot-content-drive-toolbar/dot-content-drive-toolbar.component'; import { DotFolderListViewContextMenuComponent } from '../components/dot-folder-list-context-menu/dot-folder-list-context-menu.component'; @@ -105,10 +109,10 @@ import { encodeFilters, isFolder } from '../utils/functions'; NgTemplateOutlet, DotContentDriveDialogFolderComponent, DotContentDriveDialogContentTypeSelectorComponent, - DotContentDriveDialogUploadSelectorComponent, + DotUploadTypeSelectorComponent, MessageModule, DotMessagePipe, - DotContentDriveDropzoneComponent, + DotUploadDropzoneComponent, DotSeverityIconComponent, DotEditContentSidePanelComponent, ProgressSpinnerModule, @@ -179,6 +183,12 @@ export class DotContentDriveShellComponent { */ readonly $treeExpanded = this.#store.isTreeVisuallyExpanded; + /** + * Folder a dropped file lands in. The shared dropzone is presentational, so the target comes + * from here rather than the dropzone reaching into the store itself. + */ + readonly $selectedFolder = computed(() => this.#store.selectedNode()?.data); + /** * Forces the folder tree visually collapsed while the Edit Content side panel is open on a * narrow viewport, and clears the override on close. Purely derived from the panel's open @@ -1141,4 +1151,12 @@ export class DotContentDriveShellComponent { protected onTableScroll() { this.#store.resetContextMenu(); } + + /** + * A file drag entering the list dismisses the context menu, which would otherwise float over + * the drop overlay. The dropzone reports the drag; deciding what it means stays here. + */ + protected onDropzoneDragEnter() { + this.#store.resetContextMenu(); + } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/lib.routes.ts index 28796fcf5770..3a77aea2000f 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/lib.routes.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/lib.routes.ts @@ -1,6 +1,6 @@ import { Route } from '@angular/router'; -import { DotContentDriveService, DotContentTypeService } from '@dotcms/data-access'; +import { DotContentTypeService } from '@dotcms/data-access'; import { DotContentDriveShellComponent } from './dot-content-drive-shell/dot-content-drive-shell.component'; @@ -8,6 +8,7 @@ export const dotContentDriveRoutes: Route[] = [ { path: '', component: DotContentDriveShellComponent, - providers: [DotContentTypeService, DotContentDriveService] + // DotContentDriveService is providedIn: 'root' (usable from dialog hosts / AssetPicker). + providers: [DotContentTypeService] } ]; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts index f3e701d40d7a..3b0e0e7858e9 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts @@ -243,28 +243,6 @@ export const ACTION_CENTER_DIALOG_CONTENT_STYLE = { export const DEFAULT_FILE_ASSET_TYPES = [{ id: 'FileAsset', name: 'File' }]; -/** - * Options shown in the upload-type selector dialog. `baseType` is the base type fired to the - * upload endpoint, which the backend resolves to the matching content type: `DOTASSET` for Assets, - * `FILEASSET` for Files. - */ -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; - /** * Options for the folder settings "Upload Behavior" radio group. `value` is persisted to the * folder's `defaultBaseType`: `null` means "ask each time" (the upload menu is shown on every @@ -316,13 +294,6 @@ export const WARNING_MESSAGE_LIFE = 4200; export const ERROR_MESSAGE_LIFE = 4500; export const MOVE_TO_FOLDER_WORKFLOW_ACTION_ID = 'dd4c4b7c-e9d3-4dc0-8fbf-36102f9c6324'; -// Dropzone state -export const DROPZONE_STATE = { - INTERNAL_DRAG: 'internal-drag', - ACTIVE: 'active', - INACTIVE: 'inactive' -} as const; - /** * `editContent` value written for a `new`-mode panel: a non-shareable marker (creating has no * identifier) whose only job is to give browser Back a history entry to pop, so Back closes the diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts index 4848f4ba2941..6ec2a097d0fc 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts @@ -6,16 +6,31 @@ import { DotFolder, DotSite } from '@dotcms/dotcms-models'; -import { DotFolderTreeNodeData, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; import { DotUVEPaletteListTypes } from '@dotcms/portlets/dot-ema/ui'; +import { DotUploadBaseType, DotUploadSelection, DotUploadSelectorPayload } from '@dotcms/ui'; -import { DIALOG_TYPE, UPLOAD_SELECTOR_OPTIONS } from './constants'; +import { DIALOG_TYPE } from './constants'; /** - * Base types the upload selector can produce, derived from the selector options so the type and the - * rendered choices never drift apart. + * The parameters for the buildTreeFolderNodes function. + * + * @export + * @interface BuildTreeFolderNodesParams */ -export type DotContentDriveUploadBaseType = (typeof UPLOAD_SELECTOR_OPTIONS)[number]['baseType']; +export interface BuildTreeFolderNodesParams { + folderHierarchyLevels: DotFolder[][]; + targetPath: string; + rootNode: DotFolderTreeNodeItem; +} + +/** + * Upload-flow types now live in `@dotcms/ui`, shared with the AssetPicker. Aliased here so the + * portlet keeps its own naming. + */ +export type DotContentDriveUploadBaseType = DotUploadBaseType; +export type DotContentDriveUploadSelectorPayload = DotUploadSelectorPayload; +export type DotContentDriveUploadSelection = DotUploadSelection; /** * The status of the content drive. @@ -151,27 +166,6 @@ export interface DotContentDriveContentTypeSelectorPayload { listType: DotUVEPaletteListTypes; } -/** - * Payload passed INTO the upload-type selector dialog. `files` is present for the drag-and-drop - * flow (the dropped files are already known) and absent for the Upload-button flow (the OS file - * picker opens after the user picks a type). - */ -export interface DotContentDriveUploadSelectorPayload { - targetFolder?: DotFolderTreeNodeData; - files?: FileList; -} - -/** - * Object emitted BACK by the upload-type selector dialog. Carries everything needed to trigger the - * upload (and, in the future, to remember the chosen type per folder — see epic #35436). - * `targetFolder` is omitted when nothing is selected (uploads to the site root). - */ -export interface DotContentDriveUploadSelection { - baseType: DotContentDriveUploadBaseType; - targetFolder?: DotFolderTreeNodeData; - files?: FileList; -} - export interface DotContentDrivePage { hasMoreContent: boolean; hasMoreFolders: boolean; @@ -274,15 +268,3 @@ export type DotContentDriveFilters = Partial & { * @interface DotContentDriveDecodeFunction */ export type DotContentDriveDecodeFunction = (value: string) => string | string[]; - -/** - * The parameters for the buildTreeFolderNodes function. - * - * @export - * @interface buildTreeFolderNodesParams - */ -export interface BuildTreeFolderNodesParams { - folderHierarchyLevels: DotFolder[][]; - targetPath: string; - rootNode: DotFolderTreeNodeItem; -} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts index c4ab2be8edf9..3060216d855a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts @@ -5,7 +5,8 @@ import { NEVER, of } from 'rxjs'; import { DotFolderService } from '@dotcms/data-access'; import { DotPagination, FolderSearchView } from '@dotcms/dotcms-models'; -import { ALL_FOLDER, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { ALL_FOLDER } from '@dotcms/ui'; import { createFakeFolderSearchView, createFakeSite } from '@dotcms/utils-testing'; import { withSidebar } from './withSidebar'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index 7d2e23b9cf8c..47b1c995a292 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -13,7 +13,8 @@ import { inject } from '@angular/core'; import { catchError, take } from 'rxjs/operators'; import { DotFolderService } from '@dotcms/data-access'; -import { ALL_FOLDER, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; +import { ALL_FOLDER } from '@dotcms/ui'; import { SYSTEM_HOST } from '../../../shared/constants'; import { DotContentDriveState } from '../../../shared/models'; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts index 8c1b357362b3..5593b37c2bec 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts @@ -1,681 +1,37 @@ -import { DotFolder } from '@dotcms/dotcms-models'; -import { ALL_FOLDER, DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; - -import { buildTreeFolderNodes, createTreeNode, generateAllParentPaths } from './tree-folder.utils'; - -describe('Sidebar Utils', () => { - describe('ALL_FOLDER constant', () => { - it('should have correct structure', () => { - expect(ALL_FOLDER).toEqual({ - 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 - }); - }); - - it('should be a folder type', () => { - expect(ALL_FOLDER.data.type).toBe('folder'); - }); - - it('should be expanded by default', () => { - expect(ALL_FOLDER.expanded).toBe(true); - }); - - it('should not be a leaf node', () => { - expect(ALL_FOLDER.leaf).toBe(false); - }); - - it('should use a native PrimeNG folder icon', () => { - expect(ALL_FOLDER.icon).toBe('pi pi-folder'); +import { ALL_FOLDER } from '@dotcms/ui'; + +describe('ALL_FOLDER constant', () => { + it('should have correct structure', () => { + expect(ALL_FOLDER).toEqual({ + 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 }); }); - describe('generateAllParentPaths', () => { - it('should generate parent paths for a simple path', () => { - const result = generateAllParentPaths('/folder1/'); - expect(result).toEqual(['/folder1/']); - }); - - it('should generate parent paths for nested folders', () => { - const result = generateAllParentPaths('/folder1/folder2/folder3/'); - expect(result).toEqual(['/folder1/', '/folder1/folder2/', '/folder1/folder2/folder3/']); - }); - - it('should handle paths without trailing slash', () => { - const result = generateAllParentPaths('/folder1/folder2'); - expect(result).toEqual(['/folder1/', '/folder1/folder2/']); - }); - - it('should handle empty path', () => { - const result = generateAllParentPaths(''); - expect(result).toEqual([]); - }); - - it('should handle single slash', () => { - const result = generateAllParentPaths('/'); - expect(result).toEqual([]); - }); - - it('should handle path with multiple consecutive slashes', () => { - const result = generateAllParentPaths('/folder1//folder2/'); - expect(result).toEqual(['/folder1/', '/folder1/folder2/']); - }); - - it('should handle complex nested path', () => { - const result = generateAllParentPaths('/path1/path2/path3/'); - expect(result).toEqual(['/path1/', '/path1/path2/', '/path1/path2/path3/']); - }); - - it('should handle path with special characters', () => { - const result = generateAllParentPaths('/folder-1/folder_2/folder.3/'); - expect(result).toEqual([ - '/folder-1/', - '/folder-1/folder_2/', - '/folder-1/folder_2/folder.3/' - ]); - }); + it('should be a folder type', () => { + expect(ALL_FOLDER.data.type).toBe('folder'); }); - describe('createTreeNode', () => { - const mockFolder: DotFolder = { - id: 'folder-123', - inode: 'folder-inode-123', - path: '/documents/', - hostName: 'demo.dotcms.com', - addChildrenAllowed: true - }; - - it('should create a tree node without parent, carrying the folder inode', () => { - const result = createTreeNode(mockFolder); - - expect(result).toEqual({ - key: 'folder-123', - label: '/documents/', - data: { - id: 'folder-123', - inode: 'folder-inode-123', - hostname: 'demo.dotcms.com', - path: '/documents/', - type: 'folder' - }, - leaf: false - }); - }); - - it('should create a tree node with parent', () => { - const parentNode: DotFolderTreeNodeItem = { - key: 'parent-123', - label: 'Parent', - data: { - id: 'parent-123', - hostname: 'demo.dotcms.com', - path: '/parent/', - type: 'folder' - }, - leaf: false - }; - - const result = createTreeNode(mockFolder, parentNode); - - expect(result).toEqual({ - parent: parentNode, - key: 'folder-123', - label: '/documents/', - data: { - id: 'folder-123', - inode: 'folder-inode-123', - hostname: 'demo.dotcms.com', - path: '/documents/', - type: 'folder' - }, - leaf: false - }); - }); - - it('should leave the node expandable (leaf false) when hasChildren is undefined', () => { - const result = createTreeNode(mockFolder); - expect(result.leaf).toBe(false); - }); - - it('should keep the node expandable (leaf false) when the folder has children', () => { - const result = createTreeNode({ ...mockFolder, hasChildren: true }); - expect(result.leaf).toBe(false); - }); - - it('should mark the node as a leaf (no chevron) when the folder has no children', () => { - const result = createTreeNode({ ...mockFolder, hasChildren: false }); - expect(result.leaf).toBe(true); - }); - - it('should use folder id as key', () => { - const result = createTreeNode(mockFolder); - expect(result.key).toBe(mockFolder.id); - }); - - it('should carry the folder defaultBaseType onto the node data', () => { - const result = createTreeNode({ ...mockFolder, defaultBaseType: 'FILEASSET' }); - expect(result.data.defaultBaseType).toBe('FILEASSET'); - }); - - it('should use folder path as label', () => { - const result = createTreeNode(mockFolder); - expect(result.label).toBe(mockFolder.path); - }); - - it('should set correct data properties', () => { - const result = createTreeNode(mockFolder); - - expect(result.data).toEqual({ - id: mockFolder.id, - inode: mockFolder.inode, - hostname: mockFolder.hostName, - path: mockFolder.path, - type: 'folder' - }); - }); - - it('should handle folder with different hostname', () => { - const folderWithDifferentHost: DotFolder = { - ...mockFolder, - hostName: 'other.dotcms.com' - }; - - const result = createTreeNode(folderWithDifferentHost); - - expect(result.data.hostname).toBe('other.dotcms.com'); - }); - - it('should handle folder with empty path', () => { - const folderWithEmptyPath: DotFolder = { - ...mockFolder, - path: '' - }; - - const result = createTreeNode(folderWithEmptyPath); - - expect(result.label).toBe(''); - expect(result.data.path).toBe(''); - }); - - it('should maintain parent reference correctly', () => { - const parentNode: DotFolderTreeNodeItem = { - key: 'parent-456', - label: 'Parent Folder', - data: { - id: 'parent-456', - hostname: 'demo.dotcms.com', - path: '/parent/', - type: 'folder' - }, - leaf: false - }; - - const result = createTreeNode(mockFolder, parentNode); - - expect(result.parent).toBe(parentNode); - expect(result.parent?.key).toBe('parent-456'); - }); + it('should be expanded by default', () => { + expect(ALL_FOLDER.expanded).toBe(true); }); - describe('buildTreeFolderNodes', () => { - // Each level holds the direct children of that level (the search endpoint does not return - // the parent folder itself). - const mockFolderHierarchy: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: '513aec5b-3aaa-4df2-b306-83e77ba334d9', - path: '/activities/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: '83bb5752-4264-43c4-84c8-28176603431a', - path: '/application/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'fa455fb5-b961-4d0c-9e63-e79a8ba8622a', - path: '/blog/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: '2ad0dd36-5b07-41ac-b9f5-c7c54085ac58', - path: '/images/' - } - ], - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'd4ab08ba-6ae6-4937-9fb4-b67d801ace72', - path: '/application/apivtl/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'c7eb5d4e72030ba98d6b78d2d2279cf8', - path: '/application/block-editor/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'b8a303ae-4cb4-40bf-9f27-b5b29b3350dc', - path: '/application/containers/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: '953db9f6-fc35-4d28-be2e-6124997ea3d9', - path: '/application/templates/' - } - ] - ]; - - it('should handle empty folder hierarchy', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: [], - targetPath: '/test/', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toEqual([]); - expect(result.selectedNode).toEqual(ALL_FOLDER); - }); - - it('should build tree structure for single level hierarchy', () => { - const singleLevel: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-1', - path: '/test/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-2', - path: '/other/' - } - ] - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: singleLevel, - targetPath: '/test/', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toHaveLength(2); - expect(result.rootNodes[0]).toEqual({ - key: 'folder-1', - label: '/test/', - data: { - id: 'folder-1', - hostname: 'demo.dotcms.com', - path: '/test/', - type: 'folder' - }, - leaf: true, - children: [], - expanded: true - }); - expect(result.rootNodes[1]).toEqual({ - key: 'folder-2', - label: '/other/', - data: { - id: 'folder-2', - hostname: 'demo.dotcms.com', - path: '/other/', - type: 'folder' - }, - leaf: false - }); - expect(result.selectedNode?.key).toBe('ALL_FOLDER'); - }); - - it('should build complex tree structure with nested hierarchy', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: mockFolderHierarchy, - targetPath: '/application/', - rootNode: ALL_FOLDER - }); - - // Should have 4 root nodes - expect(result.rootNodes).toHaveLength(4); - - // Check root nodes structure - expect(result.rootNodes.map((node) => node.key)).toEqual([ - '513aec5b-3aaa-4df2-b306-83e77ba334d9', // activities - '83bb5752-4264-43c4-84c8-28176603431a', // application - 'fa455fb5-b961-4d0c-9e63-e79a8ba8622a', // blog - '2ad0dd36-5b07-41ac-b9f5-c7c54085ac58' // images - ]); - - // The application folder should be expanded and have children - const applicationNode = result.rootNodes.find( - (node) => node.key === '83bb5752-4264-43c4-84c8-28176603431a' - ); - expect(applicationNode?.expanded).toBe(true); - expect(applicationNode?.children).toHaveLength(4); - expect(applicationNode?.children?.map((child) => child.key)).toEqual([ - 'd4ab08ba-6ae6-4937-9fb4-b67d801ace72', // apivtl - 'c7eb5d4e72030ba98d6b78d2d2279cf8', // block-editor - 'b8a303ae-4cb4-40bf-9f27-b5b29b3350dc', // containers - '953db9f6-fc35-4d28-be2e-6124997ea3d9' // templates - ]); - - // Selected node should be the application folder - expect(result.selectedNode?.key).toBe('83bb5752-4264-43c4-84c8-28176603431a'); - expect(result.selectedNode?.data.path).toBe('/application/'); - }); - - it('should handle deeper nested path selection', () => { - const deepHierarchy: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'level1-folder', - path: '/level1/' - } - ], - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'level2-folder', - path: '/level1/level2/' - } - ], - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'level3-folder', - path: '/level1/level2/level3/' - } - ] - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: deepHierarchy, - targetPath: '/level1/level2/level3/', - rootNode: ALL_FOLDER - }); - - // Should have 1 root node - expect(result.rootNodes).toHaveLength(1); - - // Root node should be expanded with children - const rootNode = result.rootNodes[0]; - expect(rootNode.key).toBe('level1-folder'); - expect(rootNode.expanded).toBe(true); - expect(rootNode.children).toHaveLength(1); - - // Level 2 should also be expanded with children - const level2Node = rootNode.children?.[0]; - expect(level2Node?.key).toBe('level2-folder'); - expect(level2Node?.expanded).toBe(true); - expect(level2Node?.children).toHaveLength(1); - - // Level 3 should be the selected node - const level3Node = level2Node?.children?.[0]; - expect(level3Node?.key).toBe('level3-folder'); - // The selected node should be the last node that was found on the target path - expect(result.selectedNode?.key).toBe('level2-folder'); - }); - - it('should return ALL_FOLDER as selected when target path does not match any folder', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: mockFolderHierarchy, - targetPath: '/nonexistent/', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toHaveLength(4); - expect(result.selectedNode).toEqual(ALL_FOLDER); - }); - - it('should handle root path selection', () => { - const rootHierarchy: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-1', - path: '/test/' - } - ] - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: rootHierarchy, - targetPath: '/', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toHaveLength(1); - expect(result.selectedNode).toEqual(ALL_FOLDER); - }); - - it('should properly handle folder nodes that are not on target path', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: mockFolderHierarchy, - targetPath: '/application/', - rootNode: ALL_FOLDER - }); - - // Other root nodes should not be expanded - const activitiesNode = result.rootNodes.find( - (node) => node.key === '513aec5b-3aaa-4df2-b306-83e77ba334d9' - ); - const blogNode = result.rootNodes.find( - (node) => node.key === 'fa455fb5-b961-4d0c-9e63-e79a8ba8622a' - ); - const imagesNode = result.rootNodes.find( - (node) => node.key === '2ad0dd36-5b07-41ac-b9f5-c7c54085ac58' - ); - - expect(activitiesNode?.expanded).toBeUndefined(); - expect(activitiesNode?.children).toBeUndefined(); - expect(blogNode?.expanded).toBeUndefined(); - expect(blogNode?.children).toBeUndefined(); - expect(imagesNode?.expanded).toBeUndefined(); - expect(imagesNode?.children).toBeUndefined(); - }); - - it('should handle empty target path', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: mockFolderHierarchy, - targetPath: '', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toHaveLength(4); - expect(result.selectedNode).toEqual(ALL_FOLDER); - }); - - it('should correctly identify nodes on target path using generateAllParentPaths', () => { - const result = buildTreeFolderNodes({ - folderHierarchyLevels: mockFolderHierarchy, - targetPath: '/application/', - rootNode: ALL_FOLDER - }); - - // Verify that the correct node is identified as being on the target path - const applicationNode = result.rootNodes.find( - (node) => node.key === '83bb5752-4264-43c4-84c8-28176603431a' - ); - - expect(applicationNode?.expanded).toBe(true); - expect(applicationNode?.children).toBeDefined(); - - // Other nodes should not be on the path - const otherNodes = result.rootNodes.filter( - (node) => node.key !== '83bb5752-4264-43c4-84c8-28176603431a' - ); - - otherNodes.forEach((node) => { - expect(node.expanded).toBeUndefined(); - expect(node.children).toBeUndefined(); - }); - }); - - it('should handle folder hierarchy with missing levels gracefully', () => { - const incompleteHierarchy: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-1', - path: '/test/' - } - ] - // Missing second level that would match /test/deep/ - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: incompleteHierarchy, - targetPath: '/test/deep/', - rootNode: ALL_FOLDER - }); - - expect(result.rootNodes).toHaveLength(1); - expect(result.rootNodes[0].key).toBe('folder-1'); - expect(result.rootNodes[0].expanded).toBe(true); - expect(result.rootNodes[0].leaf).toBe(true); - expect(result.selectedNode?.key).toBe('ALL_FOLDER'); - }); - - describe('rootNode as selectedNode - Code Path Coverage', () => { - it('should set rootNode as selectedNode when folderHierarchyLevels is empty (early return path)', () => { - const customRootNode: DotFolderTreeNodeItem = { - key: 'custom-root', - label: 'Custom Root', - loading: false, - data: { - type: 'folder', - path: '/custom/', - hostname: 'test.dotcms.com', - id: 'custom-root-id' - }, - leaf: false, - expanded: true - }; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: [], - targetPath: '/some/path/', - rootNode: customRootNode - }); - - expect(result.rootNodes).toEqual([]); - expect(result.selectedNode).toBe(customRootNode); - expect(result.selectedNode.key).toBe('custom-root'); - }); - - it('should set rootNode as selectedNode when no folder matches the target path (fallback path)', () => { - const customRootNode: DotFolderTreeNodeItem = { - key: 'fallback-root', - label: 'Fallback Root', - loading: false, - data: { - type: 'folder', - path: '', - hostname: 'example.dotcms.com', - id: 'fallback-id' - }, - leaf: false, - expanded: false - }; - - const hierarchyWithNoMatch: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-1', - path: '/existing-folder/' - }, - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-2', - path: '/another-folder/' - } - ] - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: hierarchyWithNoMatch, - targetPath: '/nonexistent-path/', - rootNode: customRootNode - }); - - // Root nodes should be created from the hierarchy - expect(result.rootNodes).toHaveLength(2); - expect(result.rootNodes[0].key).toBe('folder-1'); - expect(result.rootNodes[1].key).toBe('folder-2'); - - // But since none match the target path, rootNode should be selected - expect(result.selectedNode).toBe(customRootNode); - expect(result.selectedNode.key).toBe('fallback-root'); - - // None of the root nodes should be expanded - expect(result.rootNodes[0].expanded).toBeUndefined(); - expect(result.rootNodes[1].expanded).toBeUndefined(); - }); - - it('should set rootNode as selectedNode when target path is empty string (fallback path)', () => { - const customRootNode: DotFolderTreeNodeItem = { - key: 'empty-path-root', - label: 'Empty Path Root', - loading: false, - data: { - type: 'folder', - path: '/root/', - hostname: 'site.dotcms.com', - id: 'empty-root-id' - }, - leaf: false - }; - - const hierarchy: DotFolder[][] = [ - [ - { - addChildrenAllowed: true, - hostName: 'demo.dotcms.com', - id: 'folder-1', - path: '/folder/' - } - ] - ]; - - const result = buildTreeFolderNodes({ - folderHierarchyLevels: hierarchy, - targetPath: '', - rootNode: customRootNode - }); + it('should not be a leaf node', () => { + expect(ALL_FOLDER.leaf).toBe(false); + }); - expect(result.rootNodes).toHaveLength(1); - expect(result.selectedNode).toBe(customRootNode); - expect(result.selectedNode.key).toBe('empty-path-root'); - }); - }); + it('should use a native PrimeNG folder icon', () => { + expect(ALL_FOLDER.icon).toBe('pi pi-folder'); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/index.ts b/core-web/libs/portlets/dot-content-drive/ui/src/index.ts index 02c40257bb2a..34425705b5cc 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/index.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/index.ts @@ -1,6 +1,18 @@ -export * from './lib/dot-folder-list-view/dot-folder-list-view.component'; +// Presentational list lives in @dotcms/ui; re-export for Content Drive consumers. +export { + DotFolderListViewComponent, + DOT_FOLDER_LIST_VIEW_COLUMN_TYPE, + HEADER_COLUMNS, + DOT_DRAG_ITEM +} from '@dotcms/ui'; +export type { + DotFolderListViewColumn, + DotFolderListViewColumnField, + DotFolderListViewColumnType, + DotFolderListViewFixedColumn, + DotFolderListViewSelectionMode +} from '@dotcms/ui'; + export * from './lib/dot-tree-folder/dot-tree-folder.component'; -export * from './lib/dot-chip-filter/dot-chip-filter.component'; -export * from './lib/dot-filter-list-item/dot-filter-list-item.component'; export * from './lib/shared/models'; export * from './lib/shared/constants'; diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts index cb4e5eb661e1..18c1b5c3196a 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts @@ -1,90 +1,7 @@ import { LOAD_MORE_NODE_TYPE } from '@dotcms/dotcms-models'; +import { ALL_FOLDER, SYSTEM_HOST_ID } from '@dotcms/ui'; -import { - DotFolderListViewColumn, - DotFolderListViewColumnField, - DotFolderTreeNodeItem -} from './models'; - -export { LOAD_MORE_NODE_TYPE }; - -export type DotFolderListViewFixedColumn = DotFolderListViewColumn & { - field: DotFolderListViewColumnField; -}; - -const FIXED_COLUMNS: DotFolderListViewFixedColumn[] = [ - { field: 'title', header: 'name', width: '32%', order: 1, sortable: true }, - { field: 'live', header: 'status', width: '10%', order: 2 }, - { field: 'languageId', header: 'locale', width: '10%', order: 3, sortable: true }, - { field: 'contentType', header: 'type', sortable: true, width: '15%', order: 4 }, - { field: 'modUser', header: 'Edited-By', width: '15%', order: 5, sortable: true }, - { field: 'modDate', header: 'Last-Edited', sortable: true, width: '13%', order: 6 }, - { field: 'actions', header: '', width: '5%', order: 7 } -]; - -// Sorted by order so the columns render in the intended sequence. Kept off the literal above: -// calling `.sort()` on an annotated array literal drops the contextual typing, widening each -// `field` back to `string`. -export const HEADER_COLUMNS: DotFolderListViewFixedColumn[] = [...FIXED_COLUMNS].sort( - (a, b) => a.order - b.order -); - -export const SYSTEM_HOST_ID = 'SYSTEM_HOST'; +export { ALL_FOLDER, LOAD_MORE_NODE_TYPE, SYSTEM_HOST_ID }; /** i18n key for the "Load more" node label. */ export const LOAD_MORE_LABEL_KEY = 'content-drive.tree.load-more'; - -/** - * @export - * @type DOT_DRAG_ITEM - */ -export const DOT_DRAG_ITEM = 'dotcms/item'; - -/** - * @export - * @type ALL_FOLDER - * @description All folder node - */ -export const ALL_FOLDER: DotFolderTreeNodeItem = { - 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 -}; - -/** - * Pass-through styling for the popover that hosts a chip-filter listbox. - * Removes default content padding and rounds the corners. - */ -export const CHIP_FILTER_POPOVER_PT = { - root: { class: '!rounded-lg overflow-hidden' }, - content: { class: '!p-0' } -}; - -/** - * Pass-through styling for the listbox rendered inside a chip-filter popover. - * Strips the listbox's own chrome, applies palette colors for selection/hover, - * and sizes option padding + checkbox to the content-drive design spec. - */ -export const CHIP_FILTER_LISTBOX_PT = { - root: { - class: [ - '!border-0 !rounded-none !shadow-none', - '[--p-listbox-option-padding:0_1rem]', - '[--p-listbox-option-focus-background:var(--p-slate-50)]', - '[--p-listbox-option-selected-color:var(--p-primary-700)]', - '[--p-listbox-option-selected-focus-color:var(--p-primary-700)]', - '[--p-listbox-option-selected-focus-background:var(--p-listbox-option-selected-background)]', - '[--p-checkbox-width:16px] [--p-checkbox-height:16px]' - ].join(' ') - } -}; diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/models.ts b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/models.ts index bb5b57e5edfc..a1191b4edb44 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/models.ts @@ -5,69 +5,13 @@ import type { TreeNodeContentData, TreeNodeLoadMoreData } from '@dotcms/dotcms-models'; +import type { DotUploadFiles } from '@dotcms/ui'; /** - * @export - * @interface DotFolderListViewColumn - * @description Column configuration for the folder list view - */ -/** - * 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. + * File and host folder for the drop zone. + * Alias of the shared {@link DotUploadFiles}, which now lives in `@dotcms/ui` with the upload kit. */ -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]; - -/** - * 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` — a typo used to render nothing at all. - * Caller-provided extra columns are not part of this; their fields are arbitrary. - */ -export type DotFolderListViewColumnField = - | 'title' - | 'live' - | 'languageId' - | 'contentType' - | 'modUser' - | 'modDate' - | 'actions'; - -export interface DotFolderListViewColumn { - field: string; - header: string; - /** - * Explicit width (any CSS length). Optional for caller-provided extra columns: when omitted the - * table sizes the column itself — by content for text/number, by a sensible default per type - * otherwise. Fixed columns set it explicitly. - */ - width?: string; - sortable?: boolean; - order: number; - /** How the cell value is rendered and sized. Defaults to `text` when omitted. */ - type?: DotFolderListViewColumnType; -} - -/** - * @export - * @interface DotContentDriveUploadFiles - * @description File and host folder for the drop zone - */ -export interface DotContentDriveUploadFiles { - files: FileList; - targetFolder: DotFolderTreeNodeData; -} +export type DotContentDriveUploadFiles = DotUploadFiles; /** * @export @@ -77,7 +21,8 @@ export interface DotContentDriveUploadFiles { export type DotContentDriveMoveItems = Omit; /** - * Content Drive site/folder node data — shared content fields plus drive-specific extras. + * Content Drive site/folder node data — extends the shared browser-selector node with the + * folder metadata Content Drive needs for context-menu gating and the edit-folder dialog. */ export type DotFolderTreeNodeContentData = TreeNodeContentData & { /** Folder inode — carried so the legacy content editor can pre-select this folder when creating content. */ @@ -120,19 +65,18 @@ export type DotFolderTreeNodeData = DotFolderTreeNodeContentData | TreeNodeLoadM /** * @export * @type DotFolderTreeNodeItem - * @description Tree node item + * @description Tree node item carrying Content Drive's extended folder data. */ export type DotFolderTreeNodeItem = TreeNode; +/** Re-export for consumers that import load-more data via content-drive/ui. */ +export type { TreeNodeLoadMoreData }; + /** * @export * @interface DotContentDriveTreeRightClick * @description Right-click on a folder row in the sidebar tree. Carries the original event (the * shared context menu anchors itself to it) and the folder the row renders. - * - * Folder data rather than the `TreeNode`: the tree reads the clicked row straight from the DOM, as - * its drag-and-drop already does, instead of searching its own input for a matching node. That - * keeps the component presentational, and the data is all a consumer needs to act on the folder. */ export interface DotContentDriveTreeRightClick { event: MouseEvent; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts index 2fac1b0accb7..333c68d0caec 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts @@ -10,9 +10,9 @@ import { CHIP_FILTER_LISTBOX_PT, CHIP_FILTER_POPOVER_PT, DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; + DotFilterListItemComponent, + DotMessagePipe +} from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; diff --git a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts index bd4090564457..02b775d1e8af 100644 --- a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts +++ b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts @@ -9,9 +9,9 @@ import { CHIP_FILTER_LISTBOX_PT, CHIP_FILTER_POPOVER_PT, DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; + DotFilterListItemComponent, + DotMessagePipe +} from '@dotcms/ui'; import { DotUsersListStore } from '../../store/dot-users-list.store'; diff --git a/core-web/libs/ui/src/__mocks__/primeuix-motion.ts b/core-web/libs/ui/src/__mocks__/primeuix-motion.ts index 27357437ad8a..29f3c3fa0bb7 100644 --- a/core-web/libs/ui/src/__mocks__/primeuix-motion.ts +++ b/core-web/libs/ui/src/__mocks__/primeuix-motion.ts @@ -10,11 +10,47 @@ export const DEFAULT_MOTION_OPTIONS = { autoWidth: false }; -export const createMotion = () => ({ - enter: jest.fn().mockResolvedValue(undefined), - leave: jest.fn().mockResolvedValue(undefined), - cancel: jest.fn() -}); +interface MotionEvent { + element: unknown; +} + +type MotionHook = ((event: MotionEvent) => void) | undefined; + +interface MotionOptions { + onBeforeEnter?: MotionHook; + onEnter?: MotionHook; + onAfterEnter?: MotionHook; + onBeforeLeave?: MotionHook; + onLeave?: MotionHook; + onAfterLeave?: MotionHook; +} + +/** + * Mirrors the real `createMotion`'s reduced-motion fast path: no CSS animation runs, but the + * before/start/after hooks still fire synchronously with `{ element }`. + * + * Firing them matters — PrimeNG overlays emit their public `onShow` / `onHide` from these hooks + * (`Popover.onAnimationStart` is bound to `pMotionOnEnter`), so a mock that swallowed them left + * every `(onShow)` handler dead in tests. + */ +export const createMotion = (element: unknown, options: MotionOptions = {}) => { + const run = (before: MotionHook, start: MotionHook, after: MotionHook): Promise => { + const event: MotionEvent = { element }; + + before?.(event); + start?.(event); + after?.(event); + + return Promise.resolve(); + }; + + return { + enter: jest.fn(() => run(options.onBeforeEnter, options.onEnter, options.onAfterEnter)), + leave: jest.fn(() => run(options.onBeforeLeave, options.onLeave, options.onAfterLeave)), + cancel: jest.fn(), + update: jest.fn() + }; +}; export const getMotionHooks = jest.fn(); export const getMotionMetadata = jest.fn(); diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index a7c7ae279568..9427a987f69c 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -7,6 +7,11 @@ export * from './lib/components/add-to-bundle/dot-add-to-bundle.component'; export * from './lib/components/dot-action-menu-button/dot-action-menu-button.component'; export * from './lib/components/dot-ai-image-prompt/ai-image-prompt.component'; export * from './lib/components/dot-api-link/dot-api-link.component'; +export * from './lib/components/dot-asset-picker/dot-asset-picker.component'; +export * from './lib/components/dot-asset-picker/asset-picker-config'; +export * from './lib/components/dot-asset-picker/asset-picker-dialog'; +export * from './lib/components/dot-asset-picker/last-asset-path'; +export * from './lib/components/dot-asset-picker/store/models'; // Moved out of `apps/dotcms-ui` so libs can embed it without reaching back into the app // (`dotcms-ui` -> portlet -> `@components/*` -> `dotcms-ui` is a project cycle). export * from './lib/components/dot-push-publish-env-selector/dot-push-publish-env-selector.component'; @@ -17,21 +22,28 @@ export * from './lib/components/dot-workflow-push-publish/dot-workflow-push-publ export * from './lib/components/dot-asset-search/components/dot-asset-search-dialog/dot-asset-search-dialog.component'; export * from './lib/components/dot-asset-search/dot-asset-search.component'; export * from './lib/components/dot-binary-option-selector/dot-binary-option-selector.component'; +export * from './lib/components/dot-chip-filter/dot-chip-filter.component'; +export * from './lib/components/dot-chip-filter/constants'; export * from './lib/components/dot-contentlet-status-badge/dot-contentlet-status-badge.component'; export * from './lib/components/dot-collapse-breadcrumb/dot-collapse-breadcrumb.component'; export * from './lib/components/dot-copy-button/dot-copy-button.component'; +export * from './lib/components/dot-dialog'; export * from './lib/components/dot-drop-zone/dot-drop-zone.component'; export * from './lib/components/dot-empty-container/dot-empty-container.component'; export * from './lib/components/dot-field-validation-message/dot-field-validation-message.component'; +export * from './lib/components/dot-filter-list-item/dot-filter-list-item.component'; export * from './lib/components/dot-form-dialog/dot-form-dialog.component'; export * from './lib/components/dot-info-page/dot-info-page.component'; export * from './lib/components/dot-key-value-ng/dot-key-value-ng.component'; +export * from './lib/components/dot-language-filter/dot-language-filter.component'; export * from './lib/components/dot-language-variable-selector/dot-language-variable-selector.component'; export * from './lib/components/dot-link/dot-link.component'; export * from './lib/components/dot-menu/dot-menu.component'; export * from './lib/components/dot-not-license/dot-not-license.component'; export * from './lib/components/dot-permissions-iframe-dialog/dot-permissions-iframe-dialog.component'; export * from './lib/components/dot-pages-favorite-page-empty-skeleton/dot-pages-favorite-page-empty-skeleton.component'; +export * from './lib/components/dot-search-input/dot-search-input.component'; +export * from './lib/components/dot-search-input/constants'; export * from './lib/components/dot-severity-icon/dot-severity-icon.component'; export * from './lib/components/dot-sidebar-accordion'; export * from './lib/components/dot-sidebar-header/dot-sidebar-header.component'; @@ -39,11 +51,24 @@ export * from './lib/components/dot-content-thumbnail/dot-content-thumbnail.comp export * from './lib/components/dot-content-thumbnail/models/dot-content-thumbnail.model'; export * from './lib/components/dot-content-thumbnail/utils/dot-content-thumbnail.utils'; export * from './lib/components/dot-content-type/dot-content-type.component'; +export * from './lib/components/dot-content-type-filter/dot-content-type-filter.component'; +export * from './lib/components/dot-folder-list-view/dot-folder-list-view.component'; +export * from './lib/components/dot-folder-list-view/models'; +export * from './lib/components/dot-folder-list-view/constants'; + export { DotSiteComponent } from './lib/components/dot-site/dot-site.component'; export * from './lib/components/dot-theme/dot-theme.component'; +export * from './lib/components/dot-upload-button/dot-upload-button.component'; +export * from './lib/components/dot-upload-dropzone/dot-upload-dropzone.component'; +export * from './lib/components/dot-upload-dropzone/constants'; +export * from './lib/components/dot-upload-type-selector/dot-upload-type-selector.component'; +export * from './lib/components/dot-upload-type-selector/constants'; +export * from './lib/components/dot-upload-type-selector/models'; export * from './lib/components/dot-workflow-actions/dot-workflow-actions.component'; export * from './lib/components/dot-browser-selector/dot-browser-selector.component'; export * from './lib/components/dot-folder-tree/dot-folder-tree.component'; +export * from './lib/components/dot-folder-tree/constants'; +export { LOAD_MORE_NODE_TYPE } from '@dotcms/dotcms-models'; export * from './lib/dot-icon/dot-icon.component'; export * from './lib/dot-spinner/dot-spinner.component'; export * from './lib/dot-tab-buttons/dot-tab-buttons.component'; @@ -91,6 +116,9 @@ export * from './lib/validators/dotValidators'; // Animations export * from './lib/animations/fade.animations'; +// Dialog +export * from './lib/dialog/fullscreen-dialog'; + // Monaco editor presets export * from './lib/monaco/editor-options'; diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.spec.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.spec.ts new file mode 100644 index 000000000000..95d9cf4c9a2d --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.spec.ts @@ -0,0 +1,194 @@ +import { DotSite } from '@dotcms/dotcms-models'; + +import { buildAssetPickerConfig } from './asset-picker-config'; +import { LAST_ASSET_PATH_KEY, writeLastAssetLocation } from './last-asset-path'; + +const SITE: DotSite = { + identifier: 'site-1', + hostname: 'dotcms.com', + aliases: null, + archived: false +}; + +/** Somewhere other than `SITE`, to prove the remembered site travels with the remembered path. */ +const OTHER_SITE = { siteId: 'site-2', hostname: 'blog.dotcms.com', path: '/images/' }; + +describe('buildAssetPickerConfig', () => { + beforeEach(() => window.localStorage.clear()); + + describe('File field', () => { + it('should pre-select the contentlet locale', () => { + const config = buildAssetPickerConfig({ mode: 'file', site: SITE, languageId: '1' }); + + expect(config.languageId).toBe('1'); + }); + + it('should not pre-select any base type', () => { + const config = buildAssetPickerConfig({ mode: 'file', site: SITE, languageId: '1' }); + + expect(config.baseTypes).toBeUndefined(); + }); + + it('should still only offer the asset-bearing base types', () => { + // AC (#36836): the selector offers dotAsset + File Asset in BOTH modes. "Nothing + // pre-selected" must not degrade into "everything offered", or the File field lists + // Widget and Content. + const config = buildAssetPickerConfig({ mode: 'file', site: SITE, languageId: '1' }); + + expect(config.allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']); + }); + + it('should not apply a mimetype restriction', () => { + const config = buildAssetPickerConfig({ mode: 'file', site: SITE, languageId: '1' }); + + expect(config.mimeTypes).toBeUndefined(); + }); + }); + + describe('Image field', () => { + it('should pre-select the contentlet locale', () => { + const config = buildAssetPickerConfig({ mode: 'image', site: SITE, languageId: '1' }); + + expect(config.languageId).toBe('1'); + }); + + it('should pre-select the dotAsset and File Asset base types', () => { + const config = buildAssetPickerConfig({ mode: 'image', site: SITE }); + + expect(config.baseTypes).toEqual(['DOTASSET', 'FILEASSET']); + }); + + it('should offer only the asset-bearing base types', () => { + const config = buildAssetPickerConfig({ mode: 'image', site: SITE }); + + expect(config.allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']); + }); + + it('should apply the image mimetype restriction', () => { + const config = buildAssetPickerConfig({ mode: 'image', site: SITE }); + + expect(config.mimeTypes).toEqual(['image/*']); + }); + + it('should keep the mimetype restriction out of anything filter-shaped', () => { + // FR: the mime filter is transparent. It lives on the config, never in the filter bag, + // so no chip can ever render it. + const config = buildAssetPickerConfig({ mode: 'image', site: SITE }); + + expect(config.baseTypes).not.toContain('image/*'); + expect(config.languageId).not.toBe('image/*'); + }); + + it('should hand back a fresh array each call', () => { + // Callers must not be able to mutate the shared constant through the config. + const first = buildAssetPickerConfig({ mode: 'image', site: SITE }); + first.baseTypes?.push('WIDGET'); + first.allowedBaseTypes?.push('WIDGET'); + + const second = buildAssetPickerConfig({ mode: 'image', site: SITE }); + + expect(second.baseTypes).toEqual(['DOTASSET', 'FILEASSET']); + expect(second.allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']); + }); + }); + + describe('Story Block media nodes', () => { + // The Story Block opens the same picker as the fields, one mode per media node. Each is + // narrowed to its own mimetype: a `dotVideo` node pointing at an mp3 is as broken as an + // Image field returning a PDF. + it.each([ + ['video', ['video/*']], + ['audio', ['audio/*']] + ] as const)('should restrict %s to its own mimetype', (mode, mimeTypes) => { + expect(buildAssetPickerConfig({ mode, site: SITE }).mimeTypes).toEqual(mimeTypes); + }); + + it.each(['video', 'audio'] as const)( + 'should treat %s like the other media modes', + (mode) => { + const config = buildAssetPickerConfig({ mode, site: SITE, languageId: '1' }); + + expect(config.baseTypes).toEqual(['DOTASSET', 'FILEASSET']); + expect(config.allowedBaseTypes).toEqual(['DOTASSET', 'FILEASSET']); + expect(config.languageId).toBe('1'); + } + ); + + it('should hand back a fresh mimetype array each call', () => { + const first = buildAssetPickerConfig({ mode: 'video', site: SITE }); + first.mimeTypes?.push('image/*'); + + expect(buildAssetPickerConfig({ mode: 'video', site: SITE }).mimeTypes).toEqual([ + 'video/*' + ]); + }); + }); + + describe('starting location', () => { + it('should be undefined when nothing is remembered and none is given', () => { + const config = buildAssetPickerConfig({ mode: 'file', site: SITE }); + + expect(config.path).toBeUndefined(); + expect(config.browseSite).toBeUndefined(); + }); + + it('should fall back to the remembered global location', () => { + writeLastAssetLocation(OTHER_SITE); + + const config = buildAssetPickerConfig({ mode: 'file', site: SITE }); + + expect(config.path).toBe('/images/'); + }); + + it('should reopen on the remembered site, not the site being edited', () => { + // The picker browses every site, so a remembered `/images/` belongs to the site it was + // picked from — applying it to the editor's site would open a folder that may not exist. + writeLastAssetLocation(OTHER_SITE); + + const config = buildAssetPickerConfig({ mode: 'file', site: SITE }); + + expect(config.browseSite).toEqual({ + identifier: 'site-2', + hostname: 'blog.dotcms.com' + }); + // The entry site still travels through — it is the upload fallback. + expect(config.site).toBe(SITE); + }); + + it('should apply a legacy site-less path to the site being edited', () => { + window.localStorage.setItem(LAST_ASSET_PATH_KEY, '"/images/"'); + + const config = buildAssetPickerConfig({ mode: 'file', site: SITE }); + + expect(config.path).toBe('/images/'); + expect(config.browseSite).toBeUndefined(); + }); + + it('should prefer an explicit path over the remembered one', () => { + writeLastAssetLocation(OTHER_SITE); + + const config = buildAssetPickerConfig({ + mode: 'file', + site: SITE, + initialAssetPath: '/docs/' + }); + + expect(config.path).toBe('/docs/'); + // An explicit path is about the entry site, so the remembered site must not tag along. + expect(config.browseSite).toBeUndefined(); + }); + + it('should share the remembered location across modes', () => { + // The value is global, not per field: a location stored from an Image field is what a + // File field opens on next. + writeLastAssetLocation(OTHER_SITE); + + expect(buildAssetPickerConfig({ mode: 'image', site: SITE }).path).toBe('/images/'); + expect(buildAssetPickerConfig({ mode: 'file', site: SITE }).path).toBe('/images/'); + }); + }); + + it('should always carry the site through', () => { + expect(buildAssetPickerConfig({ mode: 'file', site: SITE }).site).toBe(SITE); + }); +}); diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.ts new file mode 100644 index 000000000000..4f147f443680 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.ts @@ -0,0 +1,119 @@ +import { DotCMSBaseTypesContentTypes, DotSite } from '@dotcms/dotcms-models'; + +import { readLastAssetLocation } from './last-asset-path'; +import { DotAssetPickerConfig } from './store/models'; + +/** + * What the host opened the picker for. + * + * `file` is an Edit Content File field — any asset goes. The rest are media modes, each narrowed to + * its own mimetype: `image` is the Image field *and* the Story Block's image node, `video` and + * `audio` are the Story Block's media nodes. + */ +export type DotAssetPickerMode = 'file' | 'image' | 'video' | 'audio'; + +/** Every mode that carries a mimetype restriction — i.e. everything but `file`. */ +export type DotAssetPickerMediaMode = Exclude; + +/** + * The only two base types that carry an asset. + * + * Every entry point is restricted to these — none of them can hold a Widget or a piece of Content. + * What differs is the *pre-selection*: the media modes start with both selected, `file` starts with + * none. + */ +export const ASSET_PICKER_ASSET_BASE_TYPES: DotCMSBaseTypesContentTypes[] = [ + DotCMSBaseTypesContentTypes.DOTASSET, + DotCMSBaseTypesContentTypes.FILEASSET +]; + +/** + * Mimetype narrowing per media mode, applied silently — an Image field that could return a PDF is + * broken, and so is a `dotVideo` node pointing at an mp3. + * + * `file` is absent on purpose, which is what makes it the one mode with no restriction. + */ +export const ASSET_PICKER_MIME_TYPES: Record = { + image: ['image/*'], + video: ['video/*'], + audio: ['audio/*'] +}; + +/** + * Dialog title key per entry point. The picker renders its own header, so the title travels in the + * config instead of `DynamicDialogConfig.header`. + */ +export const ASSET_PICKER_TITLE_KEYS: Record = { + file: 'dot.asset.picker.header.file', + image: 'dot.asset.picker.header.image', + video: 'dot.asset.picker.header.video', + audio: 'dot.asset.picker.header.audio' +}; + +export interface DotAssetPickerEntryOptions { + mode: DotAssetPickerMode; + + /** Site to browse. */ + site: DotSite; + + /** + * Dialog title, already translated. Callers resolve {@link ASSET_PICKER_TITLE_KEYS} — this + * module has no `DotMessageService` and stays a pure config builder. + */ + title?: string; + + /** Language of the contentlet being edited, pre-selected as the locale filter. */ + languageId?: string; + + /** + * Explicit starting folder. When omitted the picker falls back to the globally remembered + * last-used location, so it reopens where the editor last picked something. + */ + initialAssetPath?: string; +} + +/** + * Builds the picker configuration for a host entry point. + * + * Kept out of the store on purpose: `DotAssetPickerStore` is a generic browse store and should not + * know what an "Image field" or a "Story Block video node" is. This is the one place that translates + * an entry point into filters. + * + * Not pure — it reads the remembered location from storage when no explicit path is given, which is + * what makes "reopen where I left off" work without every caller remembering to do it. + */ +export function buildAssetPickerConfig({ + mode, + site, + title, + languageId, + initialAssetPath +}: DotAssetPickerEntryOptions): DotAssetPickerConfig { + // Presence in the mimetype map is what makes a mode a media mode — no `mode === 'x'` chain to + // extend the next time a media node shows up. + const mimeTypes = ASSET_PICKER_MIME_TYPES[mode as DotAssetPickerMediaMode]; + + // An explicit path is always about the entry site; a remembered one carries its own. + const remembered = initialAssetPath ? undefined : readLastAssetLocation(); + + return { + site, + // Only when the remembered site is a real, identified one — a legacy bare-path payload has + // no site, so its path is applied to the entry site instead. + ...(remembered?.siteId + ? { browseSite: { identifier: remembered.siteId, hostname: remembered.hostname } } + : {}), + ...(title ? { title } : {}), + path: initialAssetPath ?? remembered?.path, + // What the selector may offer — the same in every mode. + allowedBaseTypes: [...ASSET_PICKER_ASSET_BASE_TYPES], + ...(languageId ? { languageId } : {}), + // What starts selected, plus the silent mimetype narrowing — media modes only. + ...(mimeTypes + ? { + baseTypes: [...ASSET_PICKER_ASSET_BASE_TYPES], + mimeTypes: [...mimeTypes] + } + : {}) + }; +} diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.spec.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.spec.ts new file mode 100644 index 000000000000..c618042093f1 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.spec.ts @@ -0,0 +1,69 @@ +import { DotSite } from '@dotcms/dotcms-models'; + +import { buildAssetPickerDialogConfig } from './asset-picker-dialog'; +import { DotAssetPickerConfig } from './store/models'; + +const SITE: DotSite = { + identifier: 'site-1', + hostname: 'dotcms.com', + aliases: null, + archived: false +}; + +const DATA: DotAssetPickerConfig = { site: SITE }; + +describe('buildAssetPickerDialogConfig', () => { + it('should carry the picker configuration through as the dialog data', () => { + expect(buildAssetPickerDialogConfig(DATA).data).toBe(DATA); + }); + + describe("flags the picker's own markup depends on", () => { + // These are the picker's contract, not a caller preference — the reason this builder exists + // instead of every caller assembling its own config. + it('should hide PrimeNG chrome header', () => { + // The picker renders its own header, so PrimeNG's would be a second one. + expect(buildAssetPickerDialogConfig(DATA).showHeader).toBe(false); + }); + + it('should be maximizable', () => { + // The picker's full-screen toggle drives PrimeNG's maximized state. + expect(buildAssetPickerDialogConfig(DATA).maximizable).toBe(true); + }); + + it('should not autofocus on show', () => { + // Autofocus lands on the search input and paints a focus halo that reads as an error. + expect(buildAssetPickerDialogConfig(DATA).focusOnShow).toBe(false); + }); + + it('should let the picker fill the dialog', () => { + // Without a full-height content box the picker cannot grow with the full-screen toggle. + expect(buildAssetPickerDialogConfig(DATA).contentStyle).toEqual({ + height: '100%', + overflow: 'hidden', + padding: '0' + }); + }); + + it('should size itself without an inline max-width', () => { + // A `max-width` would still clamp the dialog once it goes full screen. + const config = buildAssetPickerDialogConfig(DATA); + + expect(config.width).toBe('min(90vw, 114rem)'); + expect(config.height).toBe('min(90vh, 68rem)'); + expect(config.style).toBeUndefined(); + }); + }); + + describe('baseZIndex', () => { + it('should be left to PrimeNG by default', () => { + expect(buildAssetPickerDialogConfig(DATA).baseZIndex).toBeUndefined(); + }); + + it('should be overridable for a caller stacked under its own backdrop', () => { + // The Story Block's full-screen shell paints at `z-[9998]`. + expect(buildAssetPickerDialogConfig(DATA, { baseZIndex: 10050 }).baseZIndex).toBe( + 10050 + ); + }); + }); +}); diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.ts new file mode 100644 index 000000000000..56d66103e0fd --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-dialog.ts @@ -0,0 +1,64 @@ +import { DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { DotAssetPickerConfig } from './store/models'; + +/** + * Centered-modal configuration for `DotAssetPickerComponent`. + * + * Every caller must go through this rather than assembling its own: the picker makes real + * assumptions about the dialog hosting it — it renders its own header, and its full-screen toggle + * drives PrimeNG's maximized state — so these flags are part of the component's contract, not a + * per-caller preference. Two callers hand-rolling them is how the Story Block and the File field + * end up looking like different features again. + * + * @param data The picker configuration, normally from `buildAssetPickerConfig`. + * @param overrides The one thing a caller may legitimately differ on — see below. + */ +export function buildAssetPickerDialogConfig( + data: DotAssetPickerConfig, + /** + * `baseZIndex` only. The Story Block's full-screen shell paints its backdrop at `z-[9998]`, so a + * picker opened from there has to be lifted above it or it renders under the shell and is + * unreachable. Nothing else here is a caller's business. + */ + overrides?: Pick +): DynamicDialogConfig { + return { + // The picker renders its own header (title + full screen + ✕), so PrimeNG's chrome header is + // hidden to avoid a duplicate and the title travels in `data`. + showHeader: false, + appendTo: 'body', + closeOnEscape: true, + closable: true, + dismissableMask: true, + draggable: false, + keepInViewport: false, + maskStyleClass: 'p-dialog-mask-dynamic', + resizable: false, + modal: true, + // The picker's own header drives full screen through PrimeNG's maximized state. No maximize + // button is rendered — PrimeNG's lives in the header we just hid. + maximizable: true, + // Autofocus would land on the picker's search input and paint the theme's focus halo the + // moment the dialog opens, which reads as an error state. + focusOnShow: false, + // Windowed size as a single `width`, not `90%` capped by a `max-width`: an inline max-width + // would still clamp the dialog once it goes full screen. + // + // The viewport-relative halves are what normally apply — the picker fills most of a laptop + // screen, so the folder tree and the asset table both breathe without reaching for full + // screen. The caps only bite on large external monitors, where a dialog that wide would just + // be hard to read. They are in `rem` so they track the content, which Tailwind sizes in + // `rem` throughout; mind that `html { font-size: 14px }` here, so 114rem/68rem are + // ~1596x952px, not the 16px-root figures you would expect. + // + // `.p-dialog` is capped at `max-height: 90%` by the theme, so asking for more than 90vh + // would have no effect. + width: 'min(90vw, 114rem)', + height: 'min(90vh, 68rem)', + // The picker fills the dialog so it can grow with the full-screen toggle. + contentStyle: { height: '100%', overflow: 'hidden', padding: '0' }, + data, + ...overrides + }; +} diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.html b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.html new file mode 100644 index 000000000000..bd4f520f8f9d --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.html @@ -0,0 +1,12 @@ + + + {{ $fullscreenIcon() }} + + diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.spec.ts new file mode 100644 index 000000000000..c3428614a8cf --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-fullscreen-toggle/dot-asset-picker-fullscreen-toggle.component.spec.ts @@ -0,0 +1,80 @@ +import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest'; + +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotAssetPickerFullscreenToggleComponent } from './dot-asset-picker-fullscreen-toggle.component'; + +import { DotAssetPickerStore } from '../../store/dot-asset-picker.store'; + +const MESSAGES = { + 'dot.asset.picker.fullscreen.enter.aria': 'Enter full screen', + 'dot.asset.picker.fullscreen.exit.aria': 'Exit full screen' +}; + +/** Only the slice of the store the toggle reads. A signal, so `computed` reacts to the toggle. */ +const createMockStore = () => { + const isFullscreen = signal(false); + + return { + isFullscreen, + toggleFullscreen: jest.fn(() => isFullscreen.set(!isFullscreen())) + }; +}; + +describe('DotAssetPickerFullscreenToggleComponent', () => { + let spectator: Spectator; + let store: ReturnType; + + const createComponent = createComponentFactory({ + component: DotAssetPickerFullscreenToggleComponent, + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }], + detectChanges: false + }); + + const clickToggle = () => { + const button = spectator + .query(byTestId('asset-picker-fullscreen-btn')) + ?.querySelector('button'); + spectator.click(button as HTMLElement); + }; + + beforeEach(() => { + store = createMockStore(); + + TestBed.overrideComponent(DotAssetPickerFullscreenToggleComponent, { + add: { providers: [{ provide: DotAssetPickerStore, useValue: store }] } + }); + + spectator = createComponent(); + spectator.detectChanges(); + }); + + it('should ask the store to toggle when clicked', () => { + clickToggle(); + + expect(store.toggleFullscreen).toHaveBeenCalledTimes(1); + }); + + // `[attr.aria-pressed]` is bound on ``, so it lands on that host element — the + // same element carrying the testid — not on the inner `