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/requests/contentlets.ts b/core-web/apps/dotcms-ui-e2e/src/requests/contentlets.ts index 3bb529c8d9f1..addf0b0c2264 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. 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..42ee844495d0 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,7 +1,9 @@ import { faker } from '@faker-js/faker'; import { NewEditContentFormPage } from '@pages'; import { expect, test } from '@playwright/test'; +import { Contentlet, createDotAsset, deleteContentlets } from '@requests/contentlets'; import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType'; +import { getDefaultSite } from '@requests/sites'; import { createFakePayloadFileField, createFakePayloadTextField @@ -10,7 +12,8 @@ import { uniqueSuffix } from '@utils/utils'; import { FileField } from './helpers/file-field'; -import { E2E_IMPORT_URL, createTestTextFile } from '../helpers/file-test-data'; +import { AssetPickerDialog } from '../helpers/asset-picker-dialog'; +import { E2E_IMPORT_URL, createTestPngFile, createTestTextFile } from '../helpers/file-test-data'; const FILE_FIELD_VARIABLE = 'fileField'; const TEST_FILE = createTestTextFile(); @@ -118,6 +121,85 @@ test('import image URL shows Edit image button', async ({ page }) => { await field.expectEditButtonVisible(); }); +test.describe('select an existing file through the AssetPicker', () => { + let seededAsset: Contentlet | null = null; + let assetName: string; + + // Seeded per test through the REST API: the picker only reads it, but a unique file name per + // test is what lets the search find exactly this asset regardless of what else the environment + // happens to contain. + // + // An image on purpose: the preview renders text assets as an editable code block + // (`code-preview`) and everything else as thumbnail + metadata, so a .txt here would never + // produce the file name this test asserts on. + test.beforeEach(async ({ request }) => { + const site = await getDefaultSite(request); + seededAsset = await createDotAsset( + request, + createTestPngFile(`e2e-picker-${uniqueSuffix()}.png`), + site.identifier + ); + assetName = seededAsset.title; + }); + + test.afterEach(async ({ request }) => { + if (seededAsset) { + await deleteContentlets(request, [seededAsset.identifier]); + seededAsset = null; + } + }); + + test('open the picker, select a file, and populate the field @critical', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new FileField(page, FILE_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openSelectExistingDialog(); + await picker.waitForVisible(); + + // Nothing picked yet, so there is nothing to confirm. + await picker.expectConfirmDisabled(); + + await picker.searchFor(assetName); + await picker.expectRowVisible(assetName); + + // Clicking the title, not the row padding: the whole row is the selection target here. + await picker.selectRowByTitle(assetName); + await picker.expectRowSelected(assetName); + await picker.expectConfirmEnabled(); + + await picker.confirm(); + + await picker.expectClosed(); + await field.expectPreviewVisible(); + await field.expectThumbnailVisible(); + await field.expectPreviewShowsFileName(assetName); + }); + + test('cancel the picker and leave the field untouched', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new FileField(page, FILE_FIELD_VARIABLE); + await field.expectVisible(); + + const picker = new AssetPickerDialog(page); + await field.openSelectExistingDialog(); + await picker.waitForVisible(); + + await picker.searchFor(assetName); + await picker.selectRowByTitle(assetName); + await picker.cancel(); + + await picker.expectClosed(); + // Highlighting a row and backing out must not populate the field. + await field.expectPreviewHidden(); + }); +}); + test.describe('required file field', () => { let requiredContentType: ContentType; let requiredContentTypeVariable: string; 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/helpers/asset-picker-dialog.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/helpers/asset-picker-dialog.ts new file mode 100644 index 000000000000..37e397e4b268 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/helpers/asset-picker-dialog.ts @@ -0,0 +1,113 @@ +import { type Locator, type Page, expect } from '@playwright/test'; + +/** + * Locator helper for the AssetPicker dialog — the "Select Existing File/Image" modal opened from a + * File or Image field. + * + * The picker renders its own header (the dialog is opened with `showHeader: false`), so everything + * here is scoped to the picker root rather than to PrimeNG's chrome. + */ +export class AssetPickerDialog { + readonly root: Locator; + readonly title: Locator; + readonly closeButton: Locator; + readonly fullscreenButton: Locator; + readonly search: Locator; + readonly sidebar: Locator; + readonly treeSearch: Locator; + readonly list: Locator; + readonly rows: Locator; + readonly cancelButton: Locator; + readonly confirmButton: Locator; + + constructor(private page: Page) { + this.root = page.getByTestId('asset-picker'); + this.title = this.root.getByTestId('asset-picker-title'); + this.closeButton = this.root.getByTestId('asset-picker-close-btn'); + this.fullscreenButton = this.root.getByTestId('asset-picker-fullscreen-btn'); + // Two search boxes are on screen at once, so each carries its own id — a shared one made + // every selector here ambiguous and was what broke this suite in CI. + this.search = this.root.getByTestId('asset-picker-search-input'); + this.sidebar = this.root.getByTestId('asset-picker-sidebar'); + this.treeSearch = this.root.getByTestId('asset-picker-tree-search-input'); + this.list = this.root.getByTestId('asset-picker-list'); + this.rows = this.list.getByTestId('item-row'); + this.cancelButton = this.root.getByTestId('asset-picker-cancel'); + this.confirmButton = this.root.getByTestId('asset-picker-confirm'); + } + + async waitForVisible(): Promise { + 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/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..f872104e6867 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,7 +1,9 @@ import { faker } from '@faker-js/faker'; import { NewEditContentFormPage } from '@pages'; import { expect, test } from '@playwright/test'; +import { Contentlet, createDotAsset, deleteContentlets } from '@requests/contentlets'; import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType'; +import { getDefaultSite } from '@requests/sites'; import { createFakePayloadImageField, createFakePayloadTextField @@ -10,7 +12,8 @@ import { uniqueSuffix } from '@utils/utils'; import { ImageField } from './helpers/image-field'; -import { createTestPngFile } from '../helpers/file-test-data'; +import { AssetPickerDialog } from '../helpers/asset-picker-dialog'; +import { createTestPngFile, createTestTextFile } from '../helpers/file-test-data'; const IMAGE_FIELD_VARIABLE = 'imageField'; const TEST_IMAGE = createTestPngFile(); @@ -120,6 +123,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/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/index.ts b/core-web/libs/data-access/src/index.ts index 578d42570b24..7d7367cb5a24 100644 --- a/core-web/libs/data-access/src/index.ts +++ b/core-web/libs/data-access/src/index.ts @@ -28,6 +28,8 @@ export * from './lib/dot-favorite-contenttype/dot-favorite-contenttype.service'; export * from './lib/dot-favorite-page/dot-favorite-page.service'; export * from './lib/dot-field/dot-field.service'; export * from './lib/dot-folder/dot-folder.service'; +export * from './lib/dot-folder/folder-tree.utils'; +export * from './lib/dot-folder/folder-tree-load.utils'; export * from './lib/dot-format-date/dot-format-date.service'; export * from './lib/dot-generate-secure-password/dot-generate-secure-password.service'; export * from './lib/dot-global-message/dot-global-message.service'; 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/data-access/src/lib/dot-folder/folder-tree-load.utils.spec.ts b/core-web/libs/data-access/src/lib/dot-folder/folder-tree-load.utils.spec.ts new file mode 100644 index 000000000000..0a5354f40e27 --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-folder/folder-tree-load.utils.spec.ts @@ -0,0 +1,523 @@ +import { describe, expect, it } from '@jest/globals'; +import { of, throwError } from 'rxjs'; + +import { DotPagination, FolderSearchView, isTreeNodeContentData } from '@dotcms/dotcms-models'; +import { createFakeFolderSearchView, createFakeSite } from '@dotcms/utils-testing'; + +import { DotFolderService } from './dot-folder.service'; +import { + applyLoadMoreToHierarchy, + buildLoadMoreNode, + folderSearchViewToDotFolder, + FOLDER_TREE_HIERARCHY_PAGE_SIZE, + FOLDER_TREE_PAGE_SIZE, + getFolderHierarchyByPath, + getFolderNodesByPath +} from './folder-tree-load.utils'; +import { createTreeNode } from './folder-tree.utils'; + +describe('folder-tree-load.utils', () => { + describe('getFolderHierarchyByPath', () => { + let mockDotFolderService: jest.Mocked; + const SITE_ID = 'site-123'; + const HOSTNAME = 'test.com'; + const SITE = createFakeSite({ identifier: SITE_ID, hostname: HOSTNAME }); + + const searchResult = (folders: FolderSearchView[]) => + of({ folders, pagination: {} as DotPagination }); + + beforeEach(() => { + mockDotFolderService = { + searchFolders: jest.fn().mockReturnValue(searchResult([])) + } as unknown as jest.Mocked; + }); + + it('should search the root and every parent path with the hierarchy page size', (done) => { + const folderPath = '/main/sub-folder/inner-folder'; + + getFolderHierarchyByPath(folderPath, SITE, mockDotFolderService).subscribe({ + next: () => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(4); + + const expectedPaths = [ + '/', + '/main/', + '/main/sub-folder/', + '/main/sub-folder/inner-folder/' + ]; + expectedPaths.forEach((path) => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ + siteId: SITE_ID, + path, + recursive: false, + page: 1, + per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE + }) + ); + }); + done(); + }, + error: done + }); + }); + + it('should adapt search results into DotFolder full paths with the site hostname', (done) => { + mockDotFolderService.searchFolders.mockReturnValueOnce( + searchResult([ + createFakeFolderSearchView({ + id: 'm', + inode: 'im', + name: 'main', + path: '/', + addChildrenAllowed: true, + hasChildren: true + }) + ]) + ); + + getFolderHierarchyByPath('/main', SITE, mockDotFolderService).subscribe({ + next: (levels) => { + expect(levels[0].folders[0]).toEqual({ + id: 'm', + inode: 'im', + hostName: HOSTNAME, + path: '/main/', + addChildrenAllowed: true, + hasChildren: true + }); + done(); + }, + error: done + }); + }); + + it('should query only the site root for the root path', (done) => { + getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ + next: (levels) => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(1); + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ path: '/' }) + ); + expect(levels).toHaveLength(1); + done(); + }, + error: done + }); + }); + + it('should query only the site root for an empty path', (done) => { + getFolderHierarchyByPath('', SITE, mockDotFolderService).subscribe({ + next: () => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(1); + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ path: '/' }) + ); + done(); + }, + error: done + }); + }); + + it('should request the large hierarchy page size (not the interactive 40)', (done) => { + const many = Array.from({ length: 45 }, (_, i) => + createFakeFolderSearchView({ id: `f${i}`, name: `folder-${i}`, path: '/' }) + ); + mockDotFolderService.searchFolders.mockReturnValue(searchResult(many)); + + getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ + next: (levels) => { + expect(levels[0].folders).toHaveLength(45); + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE }) + ); + expect(FOLDER_TREE_HIERARCHY_PAGE_SIZE).toBeGreaterThan(FOLDER_TREE_PAGE_SIZE); + done(); + }, + error: done + }); + }); + + it('should include folders past interactive page position 40 for deep-link restore', (done) => { + // Simulates a level where the deep-linked name sorts after the first 40 siblings. + const siblings = Array.from({ length: 45 }, (_, i) => + createFakeFolderSearchView({ + id: `f${i}`, + name: `qa36151-child-${i}`, + path: '/qa36151-many-parent/' + }) + ); + mockDotFolderService.searchFolders.mockReturnValue( + of({ + folders: siblings, + pagination: { + currentPage: 1, + perPage: FOLDER_TREE_HIERARCHY_PAGE_SIZE, + totalEntries: siblings.length + } + }) + ); + + getFolderHierarchyByPath( + '/qa36151-many-parent/qa36151-child-9/', + SITE, + mockDotFolderService + ).subscribe({ + next: (levels) => { + // Hierarchy returns every sibling in one large page so a late-sorted + // name (string-sort: child-9 is past position 40) is still present. + const parentLevel = levels.find( + (level) => level.path === '/qa36151-many-parent/' + ); + expect(parentLevel).toBeDefined(); + expect(parentLevel!.folders.length).toBeGreaterThan(FOLDER_TREE_PAGE_SIZE); + expect( + parentLevel!.folders.some( + (folder) => folder.path === '/qa36151-many-parent/qa36151-child-9/' + ) + ).toBe(true); + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/qa36151-many-parent/', + per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE + }) + ); + done(); + }, + error: done + }); + }); + + it('should expose totalEntries so callers can append load-more', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + of({ + folders: [createFakeFolderSearchView({ path: '/' })], + pagination: { + currentPage: 1, + perPage: FOLDER_TREE_HIERARCHY_PAGE_SIZE, + totalEntries: FOLDER_TREE_HIERARCHY_PAGE_SIZE + 10 + } + }) + ); + + getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ + next: (levels) => { + expect(levels[0].totalEntries).toBe(FOLDER_TREE_HIERARCHY_PAGE_SIZE + 10); + expect(levels[0].path).toBe('/'); + done(); + }, + error: done + }); + }); + + it('should propagate service errors', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + throwError(() => new Error('Service error')) + ); + + getFolderHierarchyByPath('/main', SITE, mockDotFolderService).subscribe({ + next: () => done(new Error('Should have thrown an error')), + error: (error) => { + expect(error.message).toBe('Service error'); + done(); + } + }); + }); + }); + + describe('getFolderNodesByPath', () => { + let mockDotFolderService: jest.Mocked; + const SITE_ID = 'site-123'; + const HOSTNAME = 'test.com'; + const SITE = createFakeSite({ identifier: SITE_ID, hostname: HOSTNAME }); + + const searchResult = (folders: FolderSearchView[]) => + of({ folders, pagination: {} as DotPagination }); + + beforeEach(() => { + mockDotFolderService = { + searchFolders: jest.fn().mockReturnValue(searchResult([])) + } as unknown as jest.Mocked; + }); + + it('should request the given page of children with the paged size', (done) => { + const testPath = '/main/sub-folder/'; + + getFolderNodesByPath(testPath, SITE, mockDotFolderService, 3).subscribe({ + next: () => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ + siteId: SITE_ID, + path: testPath, + recursive: false, + page: 3, + per_page: FOLDER_TREE_PAGE_SIZE + }) + ); + done(); + }, + error: done + }); + }); + + it('should default to page 1', (done) => { + getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ + next: () => { + expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( + expect.objectContaining({ page: 1 }) + ); + done(); + }, + error: done + }); + }); + + it('should transform child folders into tree nodes', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + searchResult([ + createFakeFolderSearchView({ + id: 'child-1', + inode: 'inode-1', + name: 'child1', + path: '/main/sub-folder/', + addChildrenAllowed: true, + hasChildren: true + }), + createFakeFolderSearchView({ + id: 'child-2', + inode: 'inode-2', + name: 'child2', + path: '/main/sub-folder/', + addChildrenAllowed: false, + hasChildren: false + }) + ]) + ); + + getFolderNodesByPath('/main/sub-folder/', SITE, mockDotFolderService).subscribe({ + next: (result) => { + expect(result.folders).toHaveLength(2); + expect(result.folders[0]).toEqual({ + key: 'child-1', + label: '/main/sub-folder/child1/', + data: { + id: 'child-1', + inode: 'inode-1', + hostname: HOSTNAME, + path: '/main/sub-folder/child1/', + type: 'folder' + }, + // hasChildren: true → expandable (chevron shown) + leaf: false + }); + expect(result.folders[1].key).toBe('child-2'); + expect(result.folders[1].label).toBe('/main/sub-folder/child2/'); + // hasChildren: false → no chevron, cannot expand + expect(result.folders[1].leaf).toBe(true); + done(); + }, + error: done + }); + }); + + it('should normalize a parent path that is missing its trailing slash', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + searchResult([createFakeFolderSearchView({ id: 'x', name: 'sub', path: '/main' })]) + ); + + getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ + next: (result) => { + const folder = result.folders[0]; + const data = folder?.data; + + // Guard before isTreeNodeContentData — `data` is optional on TreeNode. + if (!data || !isTreeNodeContentData(data)) { + done(new Error('Expected a content folder node with path data')); + + return; + } + + // '/main' (no trailing slash) + 'sub' must yield '/main/sub/', not '/mainsub/' + expect(data.path).toBe('/main/sub/'); + expect(folder.label).toBe('/main/sub/'); + done(); + }, + error: done + }); + }); + + it('should return an empty folders array when the level has no children', (done) => { + getFolderNodesByPath('/main/empty/', SITE, mockDotFolderService).subscribe({ + next: (result) => { + expect(result.folders).toEqual([]); + done(); + }, + error: done + }); + }); + + it('should surface the level total so the caller can decide if more remain', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + of({ + folders: [createFakeFolderSearchView({ path: '/main/' })], + pagination: { + currentPage: 1, + perPage: FOLDER_TREE_PAGE_SIZE, + totalEntries: 120 + } + }) + ); + + getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ + next: (result) => { + expect(result.folders).toHaveLength(1); + expect(result.totalEntries).toBe(120); + done(); + }, + error: done + }); + }); + + it('should propagate service errors', (done) => { + mockDotFolderService.searchFolders.mockReturnValue( + throwError(() => new Error('Service error')) + ); + + getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ + next: () => done(new Error('Should have thrown an error')), + error: (error) => { + expect(error.message).toBe('Service error'); + done(); + } + }); + }); + }); + + describe('buildLoadMoreNode', () => { + it('should build a non-selectable leaf load-more node carrying the paging cursor', () => { + const node = buildLoadMoreNode('/main/', 'test.com', 2, 75); + + expect(node).toEqual({ + key: 'load-more:/main/', + label: '', + type: 'load-more', + data: { + type: 'load-more', + path: '/main/', + hostname: 'test.com', + id: 'load-more:/main/', + nextPage: 2, + remaining: 75 + }, + leaf: true, + selectable: false + }); + }); + + it('should set node.type and data.type to the same load-more value', () => { + const node = buildLoadMoreNode('/main/', 'test.com', 2, 75); + + expect(node.type).toBe('load-more'); + expect(node.data?.type).toBe('load-more'); + expect(node.type).toBe(node.data?.type); + }); + }); + + describe('applyLoadMoreToHierarchy', () => { + it('should append a load-more sentinel with nextPage 2 when more entries remain', () => { + const rootFolder = createTreeNode({ + id: 'root-1', + inode: 'inode-1', + hostName: 'test.com', + path: '/main/', + addChildrenAllowed: true + }); + + const roots = applyLoadMoreToHierarchy( + [rootFolder], + [ + { + path: '/', + folders: [ + { + id: 'root-1', + inode: 'inode-1', + hostName: 'test.com', + path: '/main/', + addChildrenAllowed: true + } + ], + totalEntries: 50 + } + ], + 'test.com' + ); + + const loadMore = roots[roots.length - 1]; + expect(loadMore.type).toBe('load-more'); + expect(loadMore.data).toEqual( + expect.objectContaining({ + type: 'load-more', + nextPage: 2, + remaining: 49 + }) + ); + }); + + it('should not append load-more when the hierarchy page already has all entries', () => { + const rootFolder = createTreeNode({ + id: 'root-1', + inode: 'inode-1', + hostName: 'test.com', + path: '/main/', + addChildrenAllowed: true + }); + + const roots = applyLoadMoreToHierarchy( + [rootFolder], + [ + { + path: '/', + folders: [ + { + id: 'root-1', + inode: 'inode-1', + hostName: 'test.com', + path: '/main/', + addChildrenAllowed: true + } + ], + totalEntries: 1 + } + ], + 'test.com' + ); + + expect(roots).toHaveLength(1); + expect(roots[0].type).not.toBe('load-more'); + }); + }); + + describe('folderSearchViewToDotFolder', () => { + it('should carry defaultBaseType through to the DotFolder', () => { + const view = createFakeFolderSearchView({ + id: 'f1', + name: 'app', + path: '/', + defaultBaseType: 'DOTASSET' + }); + + const folder = folderSearchViewToDotFolder(view, 'demo.dotcms.com'); + + expect(folder.defaultBaseType).toBe('DOTASSET'); + }); + + it('should leave defaultBaseType undefined when the view has no preference', () => { + const view = createFakeFolderSearchView({ id: 'f2', name: 'docs', path: '/' }); + + const folder = folderSearchViewToDotFolder(view, 'demo.dotcms.com'); + + expect(folder.defaultBaseType).toBeUndefined(); + }); + }); +}); diff --git a/core-web/libs/data-access/src/lib/dot-folder/folder-tree-load.utils.ts b/core-web/libs/data-access/src/lib/dot-folder/folder-tree-load.utils.ts new file mode 100644 index 000000000000..df542911469c --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-folder/folder-tree-load.utils.ts @@ -0,0 +1,264 @@ +import { forkJoin, Observable } from 'rxjs'; + +import { map } from 'rxjs/operators'; + +import { + createLoadMoreTreeNode, + DotFolder, + DotSite, + DOT_FOLDER_TREE_PAGE_SIZE, + FolderSearchView, + LOAD_MORE_NODE_TYPE, + TreeNodeItem +} from '@dotcms/dotcms-models'; + +import { DotFolderService } from './dot-folder.service'; +import { createTreeNode, generateAllParentPaths } from './folder-tree.utils'; + +/** + * Page size for interactive folder-tree expand and load-more. + * Re-exports the shared limit used by Host Folder Field so both stay in sync. + */ +export const FOLDER_TREE_PAGE_SIZE = DOT_FOLDER_TREE_PAGE_SIZE; + +/** + * Page size for the deep-link / initial hierarchy fetch only. + * One request per ancestor level (parallel); large enough that path segments past + * the interactive page of 40 still appear so `buildTreeFolderNodes` can select them. + * Expand and load-more keep using {@link FOLDER_TREE_PAGE_SIZE}. + */ +export const FOLDER_TREE_HIERARCHY_PAGE_SIZE = 10000; + +/** + * Adapts a folder search API view into a {@link DotFolder}. + * + * @param {FolderSearchView} view - The folder search result item + * @param {string} hostName - Hostname of the site being browsed + * @returns {DotFolder} The adapted folder + */ +export function folderSearchViewToDotFolder(view: FolderSearchView, hostName: string): DotFolder { + // Normalize the parent path to a trailing slash before composing the folder's own path, so the + // result is always `...//`. `buildTreeFolderNodes` compares this against + // `generateAllParentPaths` (always trailing-slashed); a missing slash would break target-path + // matching. Mirrors the guard in dot-browsing.service.ts. + const parentPath = view.path.endsWith('/') ? view.path : `${view.path}/`; + + return { + id: view.id, + inode: view.inode, + hostName, + path: `${parentPath}${view.name}/`, + addChildrenAllowed: view.addChildrenAllowed, + hasChildren: view.hasChildren, + defaultBaseType: view.defaultBaseType + }; +} + +/** + * One level of the folder hierarchy returned by {@link getFolderHierarchyByPath}. + * `path` is the parent path that was queried; `folders` are its direct children (first page). + */ +export type FolderTreeHierarchyLevel = { + path: string; + folders: DotFolder[]; + totalEntries: number; +}; + +/** + * Fetches the folders for every level of a target path using parallel search calls, so the sidebar + * tree can be rendered expanded down to that path (deep-link restore). + * + * One `GET /api/v1/folder/search` (non-recursive) call is made per level, starting at the site root + * (`'/'`) and descending through each parent path. Uses {@link FOLDER_TREE_HIERARCHY_PAGE_SIZE} + * (large, page 1 only) so ancestors past the interactive page of 40 still resolve without a + * sequential page-until-found waterfall. Interactive expand/load-more use + * {@link getFolderNodesByPath} with {@link FOLDER_TREE_PAGE_SIZE}. Callers should append load-more + * via {@link applyLoadMoreToHierarchy} when `totalEntries` exceeds the returned page. + * + * @param {string} folderPath - The folder path (without hostname) to expand to, e.g. `/a/b/` + * @param {DotSite} site - The site to scope the search (its `identifier` and `hostname` are used) + * @param {DotFolderService} dotFolderService - The folder service + * @returns {Observable} one level descriptor per path + */ +export function getFolderHierarchyByPath( + folderPath: string, + site: DotSite, + dotFolderService: DotFolderService +): Observable { + // The root level (`'/'`) is always fetched first; deeper levels come from the target path. + const paths = ['/', ...generateAllParentPaths(folderPath)]; + + const folderRequests = paths.map((path) => + dotFolderService + .searchFolders({ + siteId: site.identifier, + path, + recursive: false, + orderby: 'name', + direction: 'ASC', + page: 1, + per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE + }) + .pipe( + map(({ folders, pagination }) => ({ + path, + folders: folders.map((view) => + folderSearchViewToDotFolder(view, site.hostname) + ), + totalEntries: pagination?.totalEntries ?? folders.length + })) + ) + ); + + return forkJoin(folderRequests); +} + +/** + * Fetches one page of the direct child folders of a path and transforms them into tree nodes. + * Used to lazily load a node's children when it is expanded, and to load subsequent pages when the + * "Load more" node is clicked. + * + * @param {string} folderPath - The folder path (without hostname) whose children to fetch + * @param {DotSite} site - The site to scope the search (its `identifier` and `hostname` are used) + * @param {DotFolderService} dotFolderService - The folder service + * @param {number} [page=1] - 1-based page to request + * @returns {Observable<{ folders: TreeNodeItem[]; totalEntries: number }>} the page of + * child nodes plus the total number of children in the level (to decide whether more remain) + */ +export function getFolderNodesByPath( + folderPath: string, + site: DotSite, + dotFolderService: DotFolderService, + page = 1 +): Observable<{ folders: TreeNodeItem[]; totalEntries: number }> { + return dotFolderService + .searchFolders({ + siteId: site.identifier, + path: folderPath, + recursive: false, + orderby: 'name', + direction: 'ASC', + page, + per_page: FOLDER_TREE_PAGE_SIZE + }) + .pipe( + map(({ folders, pagination }) => ({ + folders: folders.map((view) => + createTreeNode(folderSearchViewToDotFolder(view, site.hostname)) + ), + totalEntries: pagination?.totalEntries ?? folders.length + })) + ); +} + +/** + * Builds the synthetic "Load more" node appended to the end of a paginated folder level. It is not + * a real folder: it is not selectable and carries the paging cursor (`nextPage`) and how many + * folders still remain, so clicking it can fetch and append the next page. + * + * @param {string} parentPath - Full path of the parent folder whose children are paginated + * @param {string} hostName - Hostname of the site + * @param {number} nextPage - The next 1-based page to request + * @param {number} remaining - How many folders remain to be loaded in the level + * @returns {TreeNodeItem} the load-more node + */ +export function buildLoadMoreNode( + parentPath: string, + hostName: string, + nextPage: number, + remaining: number +): TreeNodeItem { + // Leave `label` empty so DotFolderTree uses the shared loadMoreLabelKey + // (same (+) Load more chrome as Host Folder Field / Browser Selector). + return createLoadMoreTreeNode({ + levelKey: parentPath, + nextPage, + remaining, + path: parentPath, + hostname: hostName + }); +} + +/** + * Appends a "Load more" sentinel when more folders remain beyond the loaded page. + */ +export function appendLoadMoreNodes( + children: TreeNodeItem[], + totalEntries: number, + path: string, + hostname: string, + nextPage: number +): TreeNodeItem[] { + if (children.length >= totalEntries) { + return [...children]; + } + + return [ + ...children, + buildLoadMoreNode(path, hostname, nextPage, totalEntries - children.length) + ]; +} + +/** + * Applies load-more sentinels to each level of a freshly built hierarchy. + * Root-level sentinels sit as siblings of root folders; nested ones go under the parent node. + * + * Hierarchy always fetches page 1 (with {@link FOLDER_TREE_HIERARCHY_PAGE_SIZE}), so the next + * interactive page is always `2` when `totalEntries` exceeds the returned folders. + */ +export function applyLoadMoreToHierarchy( + rootNodes: TreeNodeItem[], + levels: FolderTreeHierarchyLevel[], + hostname: string +): TreeNodeItem[] { + if (!levels.length) { + return rootNodes; + } + + const nextPageAfterHierarchy = 2; + + const roots = appendLoadMoreNodes( + rootNodes, + levels[0].totalEntries, + levels[0].path, + hostname, + nextPageAfterHierarchy + ); + + for (let i = 1; i < levels.length; i++) { + const level = levels[i]; + const parent = findFolderNodeByPath(level.path, roots); + + if (!parent) { + continue; + } + + parent.children = appendLoadMoreNodes( + (parent.children as TreeNodeItem[] | undefined) ?? [], + level.totalEntries, + level.path, + hostname, + nextPageAfterHierarchy + ); + } + + return roots; +} + +function findFolderNodeByPath(path: string, nodes: TreeNodeItem[]): TreeNodeItem | undefined { + for (const node of nodes) { + if (node.data?.type !== LOAD_MORE_NODE_TYPE && node.data?.path === path) { + return node; + } + + const found = node.children + ? findFolderNodeByPath(path, node.children as TreeNodeItem[]) + : undefined; + + if (found) { + return found; + } + } + + return undefined; +} diff --git a/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.spec.ts b/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.spec.ts new file mode 100644 index 000000000000..a3f3320cf61f --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.spec.ts @@ -0,0 +1,660 @@ +import { DotFolder, TreeNodeItem } from '@dotcms/dotcms-models'; + +import { buildTreeFolderNodes, createTreeNode, generateAllParentPaths } from './folder-tree.utils'; + +const ROOT_NODE: TreeNodeItem = { + key: 'ALL_FOLDER', + label: 'content-drive.all-folder.label', + loading: false, + data: { + type: 'folder', + path: '', + hostname: '', + id: '', + inode: '' + }, + icon: 'pi pi-folder', + leaf: false, + expanded: true +}; + +describe('folder-tree.utils', () => { + 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/' + ]); + }); + }); + + 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: TreeNodeItem = { + 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: TreeNodeItem = { + 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'); + }); + }); + + 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: ROOT_NODE + }); + + expect(result.rootNodes).toEqual([]); + expect(result.selectedNode).toEqual(ROOT_NODE); + }); + + 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: ROOT_NODE + }); + + 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: ROOT_NODE + }); + + // 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: ROOT_NODE + }); + + // 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 ROOT_NODE as selected when target path does not match any folder', () => { + const result = buildTreeFolderNodes({ + folderHierarchyLevels: mockFolderHierarchy, + targetPath: '/nonexistent/', + rootNode: ROOT_NODE + }); + + expect(result.rootNodes).toHaveLength(4); + expect(result.selectedNode).toEqual(ROOT_NODE); + }); + + 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: ROOT_NODE + }); + + expect(result.rootNodes).toHaveLength(1); + expect(result.selectedNode).toEqual(ROOT_NODE); + }); + + it('should properly handle folder nodes that are not on target path', () => { + const result = buildTreeFolderNodes({ + folderHierarchyLevels: mockFolderHierarchy, + targetPath: '/application/', + rootNode: ROOT_NODE + }); + + // 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: ROOT_NODE + }); + + expect(result.rootNodes).toHaveLength(4); + expect(result.selectedNode).toEqual(ROOT_NODE); + }); + + it('should correctly identify nodes on target path using generateAllParentPaths', () => { + const result = buildTreeFolderNodes({ + folderHierarchyLevels: mockFolderHierarchy, + targetPath: '/application/', + rootNode: ROOT_NODE + }); + + // 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: ROOT_NODE + }); + + 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: TreeNodeItem = { + 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: TreeNodeItem = { + 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: TreeNodeItem = { + 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 + }); + + expect(result.rootNodes).toHaveLength(1); + expect(result.selectedNode).toBe(customRootNode); + expect(result.selectedNode.key).toBe('empty-path-root'); + }); + }); + }); +}); diff --git a/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.ts b/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.ts new file mode 100644 index 000000000000..15969f6d46ab --- /dev/null +++ b/core-web/libs/data-access/src/lib/dot-folder/folder-tree.utils.ts @@ -0,0 +1,128 @@ +import { DotFolder, TreeNodeItem } from '@dotcms/dotcms-models'; + +/** + * Parameters for {@link buildTreeFolderNodes}. + */ +export interface BuildTreeFolderNodesParams { + folderHierarchyLevels: DotFolder[][]; + targetPath: string; + rootNode: TreeNodeItem; +} + +/** + * Generates all parent paths from a target path + * + * Example: + * '/path1/path2/path3/' → ['/path1/', '/path1/path2/', '/path1/path2/path3/'] + */ +export const generateAllParentPaths = (path: string): string[] => { + const segments = path.split('/').filter(Boolean); + const paths: string[] = []; + + let current = ''; + for (const segment of segments) { + current += `/${segment}`; + paths.push(current + '/'); + } + + return paths; +}; + +/** + * Transforms a DotFolder into a TreeNodeItem + * + * @param {DotFolder} folder - The folder to transform + * @returns {TreeNodeItem} The tree node item + */ +export const createTreeNode = (folder: DotFolder, parent?: TreeNodeItem): TreeNodeItem => { + let node: TreeNodeItem = { + key: folder.id, + label: folder.path, + data: { + id: folder.id, + inode: folder.inode, + hostname: folder.hostName, + path: folder.path, + type: 'folder', + defaultBaseType: folder.defaultBaseType + }, + // Hide the expand toggle for folders the search endpoint reports as having no visible + // children. When `hasChildren` is undefined (legacy source) the folder stays expandable. + leaf: folder.hasChildren === false + }; + + if (parent) { + node = { parent, ...node }; + } + + return node; +}; + +/** + * Builds the tree folder nodes + * + * @param {DotFolder[][]} folderHierarchyLevels - The folder hierarchy levels + * @param {string} targetPath - The target path + * @returns {TreeNodeItem[]} The tree folder nodes + * @returns {TreeNodeItem} The selected node + */ +export const buildTreeFolderNodes = ({ + folderHierarchyLevels, + targetPath, + rootNode +}: BuildTreeFolderNodesParams): { + rootNodes: TreeNodeItem[]; + selectedNode: TreeNodeItem; +} => { + if (folderHierarchyLevels.length === 0) { + return { rootNodes: [], selectedNode: rootNode }; + } + + const rootNodes: TreeNodeItem[] = []; + const expectedPaths = generateAllParentPaths(targetPath); + const activeParents: Record = {}; + + /** + * Checks if a folder node belongs to the active target path + */ + const isOnTargetPath = (levelIndex: number, node: TreeNodeItem) => { + const data = node.data; + return !!data && data.type !== 'load-more' && expectedPaths[levelIndex] === data.path; + }; + + /** + * Checks if a folder node is a leaf + */ + const isLeaf = (levelIndex: number) => folderHierarchyLevels.length >= levelIndex + 1; + + folderHierarchyLevels.forEach((folders, levelIndex) => { + const parentNode = activeParents[levelIndex]; + + folders.forEach((folder) => { + const node = createTreeNode(folder); + + // Root level nodes are added directly + if (levelIndex === 0) { + rootNodes.push(node); + } + // Deeper levels get attached to the active parent + else if (parentNode) { + parentNode.children = parentNode.children || []; + parentNode.children.push(node); + } + + // If this node is along the target path, mark it as active parent for the next level + if (isOnTargetPath(levelIndex, node)) { + activeParents[levelIndex + 1] = node; + node.children = []; + node.expanded = true; + node.leaf = isLeaf(levelIndex); + } + }); + }); + + // The last expanded parent is the "selected" node + const selectedNode = activeParents[folderHierarchyLevels.length - 1] || rootNode; + + return { rootNodes, selectedNode }; +}; 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..b65d063ff57a 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,15 @@ import { DotCMSContentTypeField, DotCMSTempFile, DotFileMetadata, - DotGeneratedAIImage + DotGeneratedAIImage, + DotSite } from '@dotcms/dotcms-models'; import { isImageFile } from '@dotcms/image-editor'; import { + ASSET_PICKER_TITLE_KEYS, + buildAssetPickerConfig, DotAIImagePromptComponent, - DotBrowserSelectorComponent, + DotAssetPickerComponent, DotDropZoneComponent, DotMessagePipe, DotSpinnerComponent, @@ -47,6 +51,7 @@ import { } from '@dotcms/ui'; import { getFileMetadata } from '@dotcms/utils'; +import { DotEditContentStore } from './../../../../store/edit-content.store'; import { LegacyDialogImageEditorLauncher, LegacyDojoImageEditorLauncher @@ -150,6 +155,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 +300,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 +816,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,17 +840,33 @@ 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'] : []; + 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 */ + } + }); + } + + /** 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 header = this.#dotMessageService.get(title); + const mode = isImage ? 'image' : 'file'; - this.#dialogRef = this.#dialogService.open(DotBrowserSelectorComponent, { - header, + this.#dialogRef = this.#dialogService.open(DotAssetPickerComponent, { + // 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, @@ -823,22 +876,39 @@ export class DotFileFieldComponent 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 - } + // 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' }, + // No explicit path: the picker reopens on the globally remembered folder. + data: 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/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/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 c1609873cf52..2fa2a144e51a 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 @@ -13,10 +13,10 @@ import { DotContentDriveUploadFiles, DotTreeFolderComponent, DotFolderTreeNodeItem, - DotContentDriveMoveItems, - ALL_FOLDER + DotContentDriveMoveItems } 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 6b88e12bc36a..04eb2501cdc8 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 @@ -16,18 +16,18 @@ import type { TreeNodeSelectEvent } from 'primeng/types/tree'; +import { appendLoadMoreNodes } from '@dotcms/data-access'; import { TreeNodeLoadMoreData } from '@dotcms/dotcms-models'; import { - ALL_FOLDER, DotContentDriveMoveItems, DotContentDriveUploadFiles, DotFolderTreeNodeItem, 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 } from '../../utils/functions'; import { DotContentDriveTreeTogglerComponent } from '../dot-content-drive-toolbar/components/dot-content-drive-tree-toggler/dot-content-drive-tree-toggler.component'; /** * @description DotContentDriveSidebarComponent is the component that renders the sidebar for the content drive 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 187d9ddb78c4..359e05c34f8c 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(); @@ -1030,7 +1029,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); @@ -1047,7 +1046,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(); @@ -1067,7 +1066,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"]') @@ -1075,7 +1074,7 @@ describe('DotContentDriveShellComponent', () => { spectator.triggerEventHandler(popover, 'onHide', {}); spectator.detectChanges(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeFalsy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeFalsy(); }); const dropFiles = () => @@ -1121,7 +1120,7 @@ describe('DotContentDriveShellComponent', () => { spectator.detectChanges(); expect(spectator.component.$uploadSelectorPayload()).toBeTruthy(); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeTruthy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeTruthy(); }); }); @@ -1402,7 +1401,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)', () => { @@ -1445,7 +1444,7 @@ describe('DotContentDriveShellComponent', () => { hostFolder: TARGET_FOLDER_DATA.id, indexPolicy: 'WAIT_FOR' }); - expect(spectator.query(DotContentDriveDialogUploadSelectorComponent)).toBeFalsy(); + expect(spectator.query(DotUploadTypeSelectorComponent)).toBeFalsy(); }); }); @@ -2395,7 +2394,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 b819be69d8b0..3d6c2b53b84e 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 @@ -44,23 +44,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'; @@ -103,10 +107,10 @@ import { encodeFilters, isFolder } from '../utils/functions'; NgTemplateOutlet, DotContentDriveDialogFolderComponent, DotContentDriveDialogContentTypeSelectorComponent, - DotContentDriveDialogUploadSelectorComponent, + DotUploadTypeSelectorComponent, MessageModule, DotMessagePipe, - DotContentDriveDropzoneComponent, + DotUploadDropzoneComponent, DotSeverityIconComponent, DotEditContentSidePanelComponent, ProgressSpinnerModule, @@ -172,6 +176,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 @@ -1128,4 +1138,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 116a1d6a2313..c53bf781b06f 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 @@ -1,11 +1,11 @@ -import { - DOT_FOLDER_TREE_PAGE_SIZE, - DotCMSBaseTypesContentTypes, - DotSite -} from '@dotcms/dotcms-models'; +import { FOLDER_TREE_HIERARCHY_PAGE_SIZE, FOLDER_TREE_PAGE_SIZE } from '@dotcms/data-access'; +import { DotCMSBaseTypesContentTypes, DotSite } from '@dotcms/dotcms-models'; import { DotContentDrivePage, DotContentDrivePagination, DotContentDriveSortOrder } from './models'; +/** Re-export shared folder-tree page sizes for portlet consumers. */ +export { FOLDER_TREE_HIERARCHY_PAGE_SIZE, FOLDER_TREE_PAGE_SIZE }; + // We only need the host and the identifier from this, the other properties are mostly to comply with SiteEntity interface export const SYSTEM_HOST: DotSite = { aliases: '', @@ -21,20 +21,6 @@ export const DEFAULT_PAGINATION: DotContentDrivePagination = { offset: 0 }; -/** - * Page size for interactive folder-tree expand and load-more. - * Re-exports the shared limit used by Host Folder Field so both stay in sync. - */ -export const FOLDER_TREE_PAGE_SIZE = DOT_FOLDER_TREE_PAGE_SIZE; - -/** - * Page size for the deep-link / initial hierarchy fetch only. - * One request per ancestor level (parallel); large enough that path segments past - * the interactive page of 40 still appear so {@link buildTreeFolderNodes} can select them. - * Expand and load-more keep using {@link FOLDER_TREE_PAGE_SIZE}. - */ -export const FOLDER_TREE_HIERARCHY_PAGE_SIZE = 10000; - export const DEFAULT_SORT = { field: 'modDate', order: DotContentDriveSortOrder.DESC @@ -231,28 +217,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 @@ -304,13 +268,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 52ec0b8916ac..8d354671afed 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 @@ -1,20 +1,25 @@ +import { BuildTreeFolderNodesParams as SharedBuildTreeFolderNodesParams } from '@dotcms/data-access'; import { DotCMSContentTypeField, DotContentDriveFolder, DotContentDriveItem, - DotFolder, DotSite } from '@dotcms/dotcms-models'; -import { DotFolderTreeNodeData, 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'; + +/** @deprecated Import {@link BuildTreeFolderNodesParams} from `@dotcms/data-access` instead. */ +export type BuildTreeFolderNodesParams = SharedBuildTreeFolderNodesParams; /** - * Base types the upload selector can produce, derived from the selector options so the type and the - * rendered choices never drift apart. + * Upload-flow types now live in `@dotcms/ui`, shared with the AssetPicker. Aliased here so the + * portlet keeps its own naming. */ -export type DotContentDriveUploadBaseType = (typeof UPLOAD_SELECTOR_OPTIONS)[number]['baseType']; +export type DotContentDriveUploadBaseType = DotUploadBaseType; +export type DotContentDriveUploadSelectorPayload = DotUploadSelectorPayload; +export type DotContentDriveUploadSelection = DotUploadSelection; /** * The status of the content drive. @@ -146,27 +151,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; @@ -256,15 +240,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 662cef9d338b..32921d213539 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 { 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 a7e0187b6765..b22369584c88 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 @@ -12,18 +12,19 @@ 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 { SYSTEM_HOST } from '../../../shared/constants'; -import { DotContentDriveState } from '../../../shared/models'; import { applyLoadMoreToHierarchy, + buildTreeFolderNodes, + DotFolderService, FolderTreeHierarchyLevel, getFolderHierarchyByPath, getFolderNodesByPath -} from '../../../utils/functions'; -import { buildTreeFolderNodes } from '../../../utils/tree-folder.utils'; +} from '@dotcms/data-access'; +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'; interface WithSidebarState { sidebarLoading: boolean; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.spec.ts index c1123f04c56a..d224141763ca 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.spec.ts @@ -1,35 +1,23 @@ import { describe, expect, it } from '@jest/globals'; -import { of, throwError } from 'rxjs'; -import { DotFolderService } from '@dotcms/data-access'; import { DotCMSContentlet, DotContentDriveFolder, - DotContentDriveItem, - DotPagination, - FolderSearchView, - isTreeNodeContentData + DotContentDriveItem } from '@dotcms/dotcms-models'; import { createFakeCheckboxField, createFakeDateField, - createFakeFolderSearchView, createFakeSelectField, - createFakeSite, createFakeTagField, createFakeTextField } from '@dotcms/utils-testing'; import { - applyLoadMoreToHierarchy, - buildLoadMoreNode, buildUserSearchablePayload, decodeByFilterKey, decodeFilters, encodeFilters, - folderSearchViewToDotFolder, - getFolderHierarchyByPath, - getFolderNodesByPath, getUserSearchableActive, isBinaryCheckboxField, isDateFieldFilterType, @@ -42,9 +30,7 @@ import { toLocalIsoString, workflowEntryToToken } from './functions'; -import { createTreeNode } from './tree-folder.utils'; -import { FOLDER_TREE_HIERARCHY_PAGE_SIZE, FOLDER_TREE_PAGE_SIZE } from '../shared/constants'; import { DotContentDriveFilters } from '../shared/models'; describe('Utility Functions', () => { @@ -386,488 +372,6 @@ describe('Utility Functions', () => { expect(decoded).toEqual(original); }); }); - - describe('getFolderHierarchyByPath', () => { - let mockDotFolderService: jest.Mocked; - const SITE_ID = 'site-123'; - const HOSTNAME = 'test.com'; - const SITE = createFakeSite({ identifier: SITE_ID, hostname: HOSTNAME }); - - const searchResult = (folders: FolderSearchView[]) => - of({ folders, pagination: {} as DotPagination }); - - beforeEach(() => { - mockDotFolderService = { - searchFolders: jest.fn().mockReturnValue(searchResult([])) - } as unknown as jest.Mocked; - }); - - it('should search the root and every parent path with the hierarchy page size', (done) => { - const folderPath = '/main/sub-folder/inner-folder'; - - getFolderHierarchyByPath(folderPath, SITE, mockDotFolderService).subscribe({ - next: () => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(4); - - const expectedPaths = [ - '/', - '/main/', - '/main/sub-folder/', - '/main/sub-folder/inner-folder/' - ]; - expectedPaths.forEach((path) => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ - siteId: SITE_ID, - path, - recursive: false, - page: 1, - per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE - }) - ); - }); - done(); - }, - error: done - }); - }); - - it('should adapt search results into DotFolder full paths with the site hostname', (done) => { - mockDotFolderService.searchFolders.mockReturnValueOnce( - searchResult([ - createFakeFolderSearchView({ - id: 'm', - inode: 'im', - name: 'main', - path: '/', - addChildrenAllowed: true, - hasChildren: true - }) - ]) - ); - - getFolderHierarchyByPath('/main', SITE, mockDotFolderService).subscribe({ - next: (levels) => { - expect(levels[0].folders[0]).toEqual({ - id: 'm', - inode: 'im', - hostName: HOSTNAME, - path: '/main/', - addChildrenAllowed: true, - hasChildren: true - }); - done(); - }, - error: done - }); - }); - - it('should query only the site root for the root path', (done) => { - getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ - next: (levels) => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(1); - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ path: '/' }) - ); - expect(levels).toHaveLength(1); - done(); - }, - error: done - }); - }); - - it('should query only the site root for an empty path', (done) => { - getFolderHierarchyByPath('', SITE, mockDotFolderService).subscribe({ - next: () => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledTimes(1); - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ path: '/' }) - ); - done(); - }, - error: done - }); - }); - - it('should request the large hierarchy page size (not the interactive 40)', (done) => { - const many = Array.from({ length: 45 }, (_, i) => - createFakeFolderSearchView({ id: `f${i}`, name: `folder-${i}`, path: '/' }) - ); - mockDotFolderService.searchFolders.mockReturnValue(searchResult(many)); - - getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ - next: (levels) => { - expect(levels[0].folders).toHaveLength(45); - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE }) - ); - expect(FOLDER_TREE_HIERARCHY_PAGE_SIZE).toBeGreaterThan(FOLDER_TREE_PAGE_SIZE); - done(); - }, - error: done - }); - }); - - it('should include folders past interactive page position 40 for deep-link restore', (done) => { - // Simulates a level where the deep-linked name sorts after the first 40 siblings. - const siblings = Array.from({ length: 45 }, (_, i) => - createFakeFolderSearchView({ - id: `f${i}`, - name: `qa36151-child-${i}`, - path: '/qa36151-many-parent/' - }) - ); - mockDotFolderService.searchFolders.mockReturnValue( - of({ - folders: siblings, - pagination: { - currentPage: 1, - perPage: FOLDER_TREE_HIERARCHY_PAGE_SIZE, - totalEntries: siblings.length - } - }) - ); - - getFolderHierarchyByPath( - '/qa36151-many-parent/qa36151-child-9/', - SITE, - mockDotFolderService - ).subscribe({ - next: (levels) => { - // Hierarchy returns every sibling in one large page so a late-sorted - // name (string-sort: child-9 is past position 40) is still present. - const parentLevel = levels.find( - (level) => level.path === '/qa36151-many-parent/' - ); - expect(parentLevel).toBeDefined(); - expect(parentLevel!.folders.length).toBeGreaterThan(FOLDER_TREE_PAGE_SIZE); - expect( - parentLevel!.folders.some( - (folder) => folder.path === '/qa36151-many-parent/qa36151-child-9/' - ) - ).toBe(true); - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ - path: '/qa36151-many-parent/', - per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE - }) - ); - done(); - }, - error: done - }); - }); - - it('should expose totalEntries so callers can append load-more', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - of({ - folders: [createFakeFolderSearchView({ path: '/' })], - pagination: { - currentPage: 1, - perPage: FOLDER_TREE_HIERARCHY_PAGE_SIZE, - totalEntries: FOLDER_TREE_HIERARCHY_PAGE_SIZE + 10 - } - }) - ); - - getFolderHierarchyByPath('/', SITE, mockDotFolderService).subscribe({ - next: (levels) => { - expect(levels[0].totalEntries).toBe(FOLDER_TREE_HIERARCHY_PAGE_SIZE + 10); - expect(levels[0].path).toBe('/'); - done(); - }, - error: done - }); - }); - - it('should propagate service errors', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - throwError(() => new Error('Service error')) - ); - - getFolderHierarchyByPath('/main', SITE, mockDotFolderService).subscribe({ - next: () => done(new Error('Should have thrown an error')), - error: (error) => { - expect(error.message).toBe('Service error'); - done(); - } - }); - }); - }); - - describe('getFolderNodesByPath', () => { - let mockDotFolderService: jest.Mocked; - const SITE_ID = 'site-123'; - const HOSTNAME = 'test.com'; - const SITE = createFakeSite({ identifier: SITE_ID, hostname: HOSTNAME }); - - const searchResult = (folders: FolderSearchView[]) => - of({ folders, pagination: {} as DotPagination }); - - beforeEach(() => { - mockDotFolderService = { - searchFolders: jest.fn().mockReturnValue(searchResult([])) - } as unknown as jest.Mocked; - }); - - it('should request the given page of children with the paged size', (done) => { - const testPath = '/main/sub-folder/'; - - getFolderNodesByPath(testPath, SITE, mockDotFolderService, 3).subscribe({ - next: () => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ - siteId: SITE_ID, - path: testPath, - recursive: false, - page: 3, - per_page: FOLDER_TREE_PAGE_SIZE - }) - ); - done(); - }, - error: done - }); - }); - - it('should default to page 1', (done) => { - getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ - next: () => { - expect(mockDotFolderService.searchFolders).toHaveBeenCalledWith( - expect.objectContaining({ page: 1 }) - ); - done(); - }, - error: done - }); - }); - - it('should transform child folders into tree nodes', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - searchResult([ - createFakeFolderSearchView({ - id: 'child-1', - inode: 'inode-1', - name: 'child1', - path: '/main/sub-folder/', - addChildrenAllowed: true, - hasChildren: true - }), - createFakeFolderSearchView({ - id: 'child-2', - inode: 'inode-2', - name: 'child2', - path: '/main/sub-folder/', - addChildrenAllowed: false, - hasChildren: false - }) - ]) - ); - - getFolderNodesByPath('/main/sub-folder/', SITE, mockDotFolderService).subscribe({ - next: (result) => { - expect(result.folders).toHaveLength(2); - expect(result.folders[0]).toEqual({ - key: 'child-1', - label: '/main/sub-folder/child1/', - data: { - id: 'child-1', - inode: 'inode-1', - hostname: HOSTNAME, - path: '/main/sub-folder/child1/', - type: 'folder' - }, - // hasChildren: true → expandable (chevron shown) - leaf: false - }); - expect(result.folders[1].key).toBe('child-2'); - expect(result.folders[1].label).toBe('/main/sub-folder/child2/'); - // hasChildren: false → no chevron, cannot expand - expect(result.folders[1].leaf).toBe(true); - done(); - }, - error: done - }); - }); - - it('should normalize a parent path that is missing its trailing slash', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - searchResult([createFakeFolderSearchView({ id: 'x', name: 'sub', path: '/main' })]) - ); - - getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ - next: (result) => { - const folder = result.folders[0]; - const data = folder?.data; - - // Guard before isTreeNodeContentData — `data` is optional on TreeNode. - if (!data || !isTreeNodeContentData(data)) { - done(new Error('Expected a content folder node with path data')); - - return; - } - - // '/main' (no trailing slash) + 'sub' must yield '/main/sub/', not '/mainsub/' - expect(data.path).toBe('/main/sub/'); - expect(folder.label).toBe('/main/sub/'); - done(); - }, - error: done - }); - }); - - it('should return an empty folders array when the level has no children', (done) => { - getFolderNodesByPath('/main/empty/', SITE, mockDotFolderService).subscribe({ - next: (result) => { - expect(result.folders).toEqual([]); - done(); - }, - error: done - }); - }); - - it('should surface the level total so the caller can decide if more remain', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - of({ - folders: [createFakeFolderSearchView({ path: '/main/' })], - pagination: { - currentPage: 1, - perPage: FOLDER_TREE_PAGE_SIZE, - totalEntries: 120 - } - }) - ); - - getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ - next: (result) => { - expect(result.folders).toHaveLength(1); - expect(result.totalEntries).toBe(120); - done(); - }, - error: done - }); - }); - - it('should propagate service errors', (done) => { - mockDotFolderService.searchFolders.mockReturnValue( - throwError(() => new Error('Service error')) - ); - - getFolderNodesByPath('/main/', SITE, mockDotFolderService).subscribe({ - next: () => done(new Error('Should have thrown an error')), - error: (error) => { - expect(error.message).toBe('Service error'); - done(); - } - }); - }); - }); - - describe('buildLoadMoreNode', () => { - it('should build a non-selectable leaf load-more node carrying the paging cursor', () => { - const node = buildLoadMoreNode('/main/', 'test.com', 2, 75); - - expect(node).toEqual({ - key: 'load-more:/main/', - label: '', - type: 'load-more', - data: { - type: 'load-more', - path: '/main/', - hostname: 'test.com', - id: 'load-more:/main/', - nextPage: 2, - remaining: 75 - }, - leaf: true, - selectable: false - }); - }); - - it('should set node.type and data.type to the same load-more value', () => { - const node = buildLoadMoreNode('/main/', 'test.com', 2, 75); - - expect(node.type).toBe('load-more'); - expect(node.data?.type).toBe('load-more'); - expect(node.type).toBe(node.data?.type); - }); - }); - - describe('applyLoadMoreToHierarchy', () => { - it('should append a load-more sentinel with nextPage 2 when more entries remain', () => { - const rootFolder = createTreeNode({ - id: 'root-1', - inode: 'inode-1', - hostName: 'test.com', - path: '/main/', - addChildrenAllowed: true - }); - - const roots = applyLoadMoreToHierarchy( - [rootFolder], - [ - { - path: '/', - folders: [ - { - id: 'root-1', - inode: 'inode-1', - hostName: 'test.com', - path: '/main/', - addChildrenAllowed: true - } - ], - totalEntries: 50 - } - ], - 'test.com' - ); - - const loadMore = roots[roots.length - 1]; - expect(loadMore.type).toBe('load-more'); - expect(loadMore.data).toEqual( - expect.objectContaining({ - type: 'load-more', - nextPage: 2, - remaining: 49 - }) - ); - }); - - it('should not append load-more when the hierarchy page already has all entries', () => { - const rootFolder = createTreeNode({ - id: 'root-1', - inode: 'inode-1', - hostName: 'test.com', - path: '/main/', - addChildrenAllowed: true - }); - - const roots = applyLoadMoreToHierarchy( - [rootFolder], - [ - { - path: '/', - folders: [ - { - id: 'root-1', - inode: 'inode-1', - hostName: 'test.com', - path: '/main/', - addChildrenAllowed: true - } - ], - totalEntries: 1 - } - ], - 'test.com' - ); - - expect(roots).toHaveLength(1); - expect(roots[0].type).not.toBe('load-more'); - }); - }); - describe('isFolder', () => { it('should return true for a folder item', () => { const folderItem: DotContentDriveFolder = { @@ -1197,26 +701,3 @@ describe('User-searchable field helpers', () => { }); }); }); - -describe('folderSearchViewToDotFolder', () => { - it('should carry defaultBaseType through to the DotFolder', () => { - const view = createFakeFolderSearchView({ - id: 'f1', - name: 'app', - path: '/', - defaultBaseType: 'DOTASSET' - }); - - const folder = folderSearchViewToDotFolder(view, 'demo.dotcms.com'); - - expect(folder.defaultBaseType).toBe('DOTASSET'); - }); - - it('should leave defaultBaseType undefined when the view has no preference', () => { - const view = createFakeFolderSearchView({ id: 'f2', name: 'docs', path: '/' }); - - const folder = folderSearchViewToDotFolder(view, 'demo.dotcms.com'); - - expect(folder.defaultBaseType).toBeUndefined(); - }); -}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts index eacf47db9f8e..4ccc7f2cc4b9 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts @@ -1,33 +1,19 @@ import { format } from 'date-fns'; -import { forkJoin, Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -import { DotFolderService } from '@dotcms/data-access'; import { - createLoadMoreTreeNode, DotCMSContentTypeField, DotContentDriveDateRange, DotContentDriveFolder, DotContentDriveItem, - DotContentDriveUserSearchableValue, - DotFolder, - DotSite, - FolderSearchView, - LOAD_MORE_NODE_TYPE + DotContentDriveUserSearchableValue } from '@dotcms/dotcms-models'; import { getSingleSelectableFieldOptions } from '@dotcms/edit-content'; -import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; - -import { createTreeNode, generateAllParentPaths } from './tree-folder.utils'; import { FIELD_FILTER_CHECKBOX_TYPE, FIELD_FILTER_DATE_TYPES, FIELD_FILTER_KEY_VALUE_TYPE, FIELD_FILTER_MULTI_VALUE_TYPES, - FOLDER_TREE_HIERARCHY_PAGE_SIZE, - FOLDER_TREE_PAGE_SIZE, USER_SEARCHABLE_PREFIX, USER_SEARCHABLE_VALUE_SEPARATOR } from '../shared/constants'; @@ -240,248 +226,6 @@ export function encodeFilters(filters: DotContentDriveFilters): string { .join(';'); } -/** - * Adapts a `FolderSearchView` (returned by `GET /api/v1/folder/search`) into the `DotFolder` - * shape the tree builder consumes. - * - * The search view exposes the folder's own `name` and its parent `path` separately and omits the - * hostname (the search is already scoped by site), so the folder's own full path is recomposed as - * `/` and the current site hostname is injected. - * - * @param {FolderSearchView} view - The folder search result item - * @param {string} hostName - Hostname of the site being browsed - * @returns {DotFolder} The adapted folder - */ -export function folderSearchViewToDotFolder(view: FolderSearchView, hostName: string): DotFolder { - // Normalize the parent path to a trailing slash before composing the folder's own path, so the - // result is always `...//`. `buildTreeFolderNodes` compares this against - // `generateAllParentPaths` (always trailing-slashed); a missing slash would break target-path - // matching. Mirrors the guard in dot-browsing.service.ts. - const parentPath = view.path.endsWith('/') ? view.path : `${view.path}/`; - - return { - id: view.id, - inode: view.inode, - hostName, - path: `${parentPath}${view.name}/`, - addChildrenAllowed: view.addChildrenAllowed, - hasChildren: view.hasChildren, - defaultBaseType: view.defaultBaseType - }; -} - -/** - * One level of the folder hierarchy returned by {@link getFolderHierarchyByPath}. - * `path` is the parent path that was queried; `folders` are its direct children (first page). - */ -export type FolderTreeHierarchyLevel = { - path: string; - folders: DotFolder[]; - totalEntries: number; -}; - -/** - * Fetches the folders for every level of a target path using parallel search calls, so the sidebar - * tree can be rendered expanded down to that path (deep-link restore). - * - * One `GET /api/v1/folder/search` (non-recursive) call is made per level, starting at the site root - * (`'/'`) and descending through each parent path. Uses {@link FOLDER_TREE_HIERARCHY_PAGE_SIZE} - * (large, page 1 only) so ancestors past the interactive page of 40 still resolve without a - * sequential page-until-found waterfall. Interactive expand/load-more use - * {@link getFolderNodesByPath} with {@link FOLDER_TREE_PAGE_SIZE}. Callers should append load-more - * via {@link applyLoadMoreToHierarchy} when `totalEntries` exceeds the returned page. - * - * @param {string} folderPath - The folder path (without hostname) to expand to, e.g. `/a/b/` - * @param {DotSite} site - The site to scope the search (its `identifier` and `hostname` are used) - * @param {DotFolderService} dotFolderService - The folder service - * @returns {Observable} one level descriptor per path - */ -export function getFolderHierarchyByPath( - folderPath: string, - site: DotSite, - dotFolderService: DotFolderService -): Observable { - // The root level (`'/'`) is always fetched first; deeper levels come from the target path. - const paths = ['/', ...generateAllParentPaths(folderPath)]; - - const folderRequests = paths.map((path) => - dotFolderService - .searchFolders({ - siteId: site.identifier, - path, - recursive: false, - orderby: 'name', - direction: 'ASC', - page: 1, - per_page: FOLDER_TREE_HIERARCHY_PAGE_SIZE - }) - .pipe( - map(({ folders, pagination }) => ({ - path, - folders: folders.map((view) => - folderSearchViewToDotFolder(view, site.hostname) - ), - totalEntries: pagination?.totalEntries ?? folders.length - })) - ) - ); - - return forkJoin(folderRequests); -} - -/** - * Fetches one page of the direct child folders of a path and transforms them into tree nodes. - * Used to lazily load a node's children when it is expanded, and to load subsequent pages when the - * "Load more" node is clicked. - * - * @param {string} folderPath - The folder path (without hostname) whose children to fetch - * @param {DotSite} site - The site to scope the search (its `identifier` and `hostname` are used) - * @param {DotFolderService} dotFolderService - The folder service - * @param {number} [page=1] - 1-based page to request - * @returns {Observable<{ folders: DotFolderTreeNodeItem[]; totalEntries: number }>} the page of - * child nodes plus the total number of children in the level (to decide whether more remain) - */ -export function getFolderNodesByPath( - folderPath: string, - site: DotSite, - dotFolderService: DotFolderService, - page = 1 -): Observable<{ folders: DotFolderTreeNodeItem[]; totalEntries: number }> { - return dotFolderService - .searchFolders({ - siteId: site.identifier, - path: folderPath, - recursive: false, - orderby: 'name', - direction: 'ASC', - page, - per_page: FOLDER_TREE_PAGE_SIZE - }) - .pipe( - map(({ folders, pagination }) => ({ - folders: folders.map((view) => - createTreeNode(folderSearchViewToDotFolder(view, site.hostname)) - ), - totalEntries: pagination?.totalEntries ?? folders.length - })) - ); -} - -/** - * Builds the synthetic "Load more" node appended to the end of a paginated folder level. It is not - * a real folder: it is not selectable and carries the paging cursor (`nextPage`) and how many - * folders still remain, so clicking it can fetch and append the next page. - * - * @param {string} parentPath - Full path of the parent folder whose children are paginated - * @param {string} hostName - Hostname of the site - * @param {number} nextPage - The next 1-based page to request - * @param {number} remaining - How many folders remain to be loaded in the level - * @returns {DotFolderTreeNodeItem} the load-more node - */ -export function buildLoadMoreNode( - parentPath: string, - hostName: string, - nextPage: number, - remaining: number -): DotFolderTreeNodeItem { - // Leave `label` empty so DotFolderTree uses the shared loadMoreLabelKey - // (same (+) Load more chrome as Host Folder Field / Browser Selector). - return createLoadMoreTreeNode({ - levelKey: parentPath, - nextPage, - remaining, - path: parentPath, - hostname: hostName - }) as DotFolderTreeNodeItem; -} - -/** - * Appends a "Load more" sentinel when more folders remain beyond the loaded page. - */ -export function appendLoadMoreNodes( - children: DotFolderTreeNodeItem[], - totalEntries: number, - path: string, - hostname: string, - nextPage: number -): DotFolderTreeNodeItem[] { - if (children.length >= totalEntries) { - return [...children]; - } - - return [ - ...children, - buildLoadMoreNode(path, hostname, nextPage, totalEntries - children.length) - ]; -} - -/** - * Applies load-more sentinels to each level of a freshly built hierarchy. - * Root-level sentinels sit as siblings of root folders; nested ones go under the parent node. - * - * Hierarchy always fetches page 1 (with {@link FOLDER_TREE_HIERARCHY_PAGE_SIZE}), so the next - * interactive page is always `2` when `totalEntries` exceeds the returned folders. - */ -export function applyLoadMoreToHierarchy( - rootNodes: DotFolderTreeNodeItem[], - levels: FolderTreeHierarchyLevel[], - hostname: string -): DotFolderTreeNodeItem[] { - if (!levels.length) { - return rootNodes; - } - - const nextPageAfterHierarchy = 2; - - const roots = appendLoadMoreNodes( - rootNodes, - levels[0].totalEntries, - levels[0].path, - hostname, - nextPageAfterHierarchy - ); - - for (let i = 1; i < levels.length; i++) { - const level = levels[i]; - const parent = findFolderNodeByPath(level.path, roots); - - if (!parent) { - continue; - } - - parent.children = appendLoadMoreNodes( - (parent.children as DotFolderTreeNodeItem[] | undefined) ?? [], - level.totalEntries, - level.path, - hostname, - nextPageAfterHierarchy - ); - } - - return roots; -} - -function findFolderNodeByPath( - path: string, - nodes: DotFolderTreeNodeItem[] -): DotFolderTreeNodeItem | undefined { - for (const node of nodes) { - if (node.data?.type !== LOAD_MORE_NODE_TYPE && node.data?.path === path) { - return node; - } - - const found = node.children - ? findFolderNodeByPath(path, node.children as DotFolderTreeNodeItem[]) - : undefined; - - if (found) { - return found; - } - } - - return undefined; -} - /** * Checks if an item is a folder. * 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/portlet/src/lib/utils/tree-folder.utils.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts index 26b585868f92..9f337b40b583 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts @@ -1,125 +1,10 @@ -import { DotFolder } from '@dotcms/dotcms-models'; -import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; - -import { BuildTreeFolderNodesParams } from '../shared/models'; - /** - * Generates all parent paths from a target path - * - * Example: - * '/path1/path2/path3/' → ['/path1/', '/path1/path2/', '/path1/path2/path3/'] + * @deprecated Import folder-tree builders from `@dotcms/data-access` instead. + * Thin re-exports kept for any residual relative imports during the AssetPicker series. */ -export const generateAllParentPaths = (path: string): string[] => { - const segments = path.split('/').filter(Boolean); - const paths: string[] = []; - - let current = ''; - for (const segment of segments) { - current += `/${segment}`; - paths.push(current + '/'); - } - - return paths; -}; - -/** - * Transforms a DotFolder into a TreeNodeItem - * - * @param {DotFolder} folder - The folder to transform - * @returns {DotFolderTreeNodeItem} The tree node item - */ -export const createTreeNode = ( - folder: DotFolder, - parent?: DotFolderTreeNodeItem -): DotFolderTreeNodeItem => { - let node: DotFolderTreeNodeItem = { - key: folder.id, - label: folder.path, - data: { - id: folder.id, - inode: folder.inode, - hostname: folder.hostName, - path: folder.path, - type: 'folder', - defaultBaseType: folder.defaultBaseType - }, - // Hide the expand toggle for folders the search endpoint reports as having no visible - // children. When `hasChildren` is undefined (legacy source) the folder stays expandable. - leaf: folder.hasChildren === false - }; - - if (parent) { - node = { parent, ...node }; - } - - return node; -}; - -/** - * Builds the tree folder nodes - * - * @param {DotFolder[][]} folderHierarchyLevels - The folder hierarchy levels - * @param {string} targetPath - The target path - * @returns {DotFolderTreeNodeItem[]} The tree folder nodes - * @returns {DotFolderTreeNodeItem} The selected node - */ -export const buildTreeFolderNodes = ({ - folderHierarchyLevels, - targetPath, - rootNode -}: BuildTreeFolderNodesParams): { - rootNodes: DotFolderTreeNodeItem[]; - selectedNode: DotFolderTreeNodeItem; -} => { - if (folderHierarchyLevels.length === 0) { - return { rootNodes: [], selectedNode: rootNode }; - } - - const rootNodes: DotFolderTreeNodeItem[] = []; - const expectedPaths = generateAllParentPaths(targetPath); - const activeParents: Record = {}; - - /** - * Checks if a folder node belongs to the active target path - */ - const isOnTargetPath = (levelIndex: number, node: DotFolderTreeNodeItem) => { - const data = node.data; - return !!data && data.type !== 'load-more' && expectedPaths[levelIndex] === data.path; - }; - - /** - * Checks if a folder node is a leaf - */ - const isLeaf = (levelIndex: number) => folderHierarchyLevels.length >= levelIndex + 1; - - folderHierarchyLevels.forEach((folders, levelIndex) => { - const parentNode = activeParents[levelIndex]; - - folders.forEach((folder) => { - const node = createTreeNode(folder); - - // Root level nodes are added directly - if (levelIndex === 0) { - rootNodes.push(node); - } - // Deeper levels get attached to the active parent - else if (parentNode) { - parentNode.children = parentNode.children || []; - parentNode.children.push(node); - } - - // If this node is along the target path, mark it as active parent for the next level - if (isOnTargetPath(levelIndex, node)) { - activeParents[levelIndex + 1] = node; - node.children = []; - node.expanded = true; - node.leaf = isLeaf(levelIndex); - } - }); - }); - - // The last expanded parent is the "selected" node - const selectedNode = activeParents[folderHierarchyLevels.length - 1] || rootNode; - - return { rootNodes, selectedNode }; -}; +export { + buildTreeFolderNodes, + createTreeNode, + generateAllParentPaths, + type BuildTreeFolderNodesParams +} from '@dotcms/data-access'; 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..cb7ea5fc2a09 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,16 @@ -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, + DotFolderListViewColumnType, + 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/dot-tree-folder/dot-tree-folder.component.spec.ts b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.spec.ts index a930209f3ccc..145c26001d66 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.spec.ts @@ -5,13 +5,11 @@ import { SkeletonModule } from 'primeng/skeleton'; import { Tree, TreeModule, TreeNodeExpandEvent, TreeNodeCollapseEvent } from 'primeng/tree'; import { DotMessageService } from '@dotcms/data-access'; -import { DotFolderTreeComponent, DotFolderNamePipe } from '@dotcms/ui'; +import { DotFolderTreeComponent, DotFolderNamePipe, SYSTEM_HOST_ID } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { DotTreeFolderComponent } from './dot-tree-folder.component'; -import { SYSTEM_HOST_ID } from '../shared/constants'; - // Mock DragEvent since it's not available in Jest environment class DragEventMock extends Event { override preventDefault = jest.fn(); diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.ts b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.ts index 9119dcb4659c..3bdefde81b03 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-tree-folder/dot-tree-folder.component.ts @@ -13,9 +13,8 @@ import { import { TreeNode } from 'primeng/api'; import { TreeNodeExpandEvent, TreeNodeCollapseEvent } from 'primeng/types/tree'; -import { DotFolderTreeComponent, DotFolderNamePipe, DotMessagePipe } from '@dotcms/ui'; +import { ALL_FOLDER, DotFolderTreeComponent, DotFolderNamePipe, DotMessagePipe } from '@dotcms/ui'; -import { ALL_FOLDER } from '../shared/constants'; import { DotFolderTreeNodeData, DotFolderTreeNodeItem, 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 92a9d3b70ef3..4dd7ec02e77a 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,75 +1,3 @@ import { LOAD_MORE_NODE_TYPE } from '@dotcms/dotcms-models'; -import { DotFolderListViewColumn, DotFolderTreeNodeItem } from './models'; - export { LOAD_MORE_NODE_TYPE }; - -export const HEADER_COLUMNS: DotFolderListViewColumn[] = [ - { 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 } -].sort((a, b) => a.order - b.order); // Sort the columns by order, so the columns are in the correct order in the UI - -export const SYSTEM_HOST_ID = 'SYSTEM_HOST'; - -/** 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 7ed050dac9b5..f8506d80cefe 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 @@ -1,55 +1,16 @@ -import type { TreeNode } from 'primeng/api'; +import type { + TreeNodeContentData, + TreeNodeData, + TreeNodeItem, + TreeNodeLoadMoreData +} from '@dotcms/dotcms-models'; +import type { DotUploadFiles } from '@dotcms/ui'; -import type { TreeNodeContentData, TreeNodeLoadMoreData } from '@dotcms/dotcms-models'; - -/** - * @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]; - -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 @@ -59,29 +20,24 @@ 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 — alias of shared {@link TreeNodeContentData} + * (inode / defaultBaseType / fromTable live on the shared type). */ -export type DotFolderTreeNodeContentData = TreeNodeContentData & { - /** Folder inode — carried so the legacy content editor can pre-select this folder when creating content. */ - inode?: string; - /** - * Folder upload preference (`DOTASSET`/`FILEASSET`, or `null`/absent for "ask each time"). - * Drives the folder-aware Upload button in the toolbar. - */ - defaultBaseType?: string | null; - fromTable?: boolean; -}; +export type DotFolderTreeNodeContentData = TreeNodeContentData; /** * @export * @interface DotFolderTreeNodeData * @description Discriminated tree node data (content vs load-more). */ -export type DotFolderTreeNodeData = DotFolderTreeNodeContentData | TreeNodeLoadMoreData; +export type DotFolderTreeNodeData = TreeNodeData; /** * @export * @type DotFolderTreeNodeItem - * @description Tree node item + * @description Tree node item (alias of shared {@link TreeNodeItem}). */ -export type DotFolderTreeNodeItem = TreeNode; +export type DotFolderTreeNodeItem = TreeNodeItem; + +/** Re-export for consumers that import load-more data via content-drive/ui. */ +export type { TreeNodeLoadMoreData }; 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 2c351e6a8b3d..10e214e52cb0 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -7,24 +7,34 @@ 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/last-asset-path'; +export * from './lib/components/dot-asset-picker/store/models'; 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-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'; @@ -32,11 +42,23 @@ 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 * 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'; @@ -84,6 +106,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..e2a8997a07ec --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.spec.ts @@ -0,0 +1,162 @@ +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('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..9b67348b09ac --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/asset-picker-config.ts @@ -0,0 +1,96 @@ +import { DotCMSBaseTypesContentTypes, DotSite } from '@dotcms/dotcms-models'; + +import { readLastAssetLocation } from './last-asset-path'; +import { DotAssetPickerConfig } from './store/models'; + +/** Which Edit Content field opened the picker. */ +export type DotAssetPickerMode = 'file' | 'image'; + +/** + * The only two base types that carry an asset. + * + * Both entry points are restricted to these — neither a File nor an Image field can hold a Widget + * or a piece of Content. What differs is the *pre-selection*: Image starts with both selected, + * File starts with none. + */ +export const ASSET_PICKER_ASSET_BASE_TYPES: DotCMSBaseTypesContentTypes[] = [ + DotCMSBaseTypesContentTypes.DOTASSET, + DotCMSBaseTypesContentTypes.FILEASSET +]; + +/** Applied silently — an Image field that could return a PDF is broken. */ +export const ASSET_PICKER_IMAGE_MIME_TYPES = ['image/*']; + +/** + * 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' +}; + +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 an Edit Content entry point. + * + * Kept out of the store on purpose: `DotAssetPickerStore` is a generic browse store and should not + * know what an "Image field" is. This is the one place that translates a field type 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 { + const isImage = mode === 'image'; + + // 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 both modes. + allowedBaseTypes: [...ASSET_PICKER_ASSET_BASE_TYPES], + ...(languageId ? { languageId } : {}), + // What starts selected, plus the silent mimetype narrowing — Image only. + ...(isImage + ? { + baseTypes: [...ASSET_PICKER_ASSET_BASE_TYPES], + mimeTypes: [...ASSET_PICKER_IMAGE_MIME_TYPES] + } + : {}) + }; +} diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.html b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.html new file mode 100644 index 000000000000..ef0f52894e1b --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.html @@ -0,0 +1,29 @@ +
+

{{ $title() }}

+ +
+ + + {{ $fullscreenIcon() }} + + + + + + close + + +
+
diff --git a/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.spec.ts new file mode 100644 index 000000000000..c899dd383945 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-asset-picker/components/dot-asset-picker-header/dot-asset-picker-header.component.spec.ts @@ -0,0 +1,104 @@ +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 { DotAssetPickerHeaderComponent } from './dot-asset-picker-header.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', + 'dot.asset.picker.close.aria': 'Close' +}; + +/** Only the slice of the store the header reads. A signal, so `computed` reacts to the toggle. */ +const createMockStore = () => { + const isFullscreen = signal(false); + + return { + isFullscreen, + toggleFullscreen: jest.fn(() => isFullscreen.set(!isFullscreen())) + }; +}; + +describe('DotAssetPickerHeaderComponent', () => { + let spectator: Spectator; + let store: ReturnType; + + const createComponent = createComponentFactory({ + component: DotAssetPickerHeaderComponent, + providers: [{ provide: DotMessageService, useValue: new MockDotMessageService(MESSAGES) }], + detectChanges: false + }); + + const clickButton = (testId: string) => { + const button = spectator.query(byTestId(testId))?.querySelector('button'); + spectator.click(button as HTMLElement); + }; + + beforeEach(() => { + store = createMockStore(); + + TestBed.overrideComponent(DotAssetPickerHeaderComponent, { + add: { providers: [{ provide: DotAssetPickerStore, useValue: store }] } + }); + + spectator = createComponent({ props: { title: 'Add Image' } }); + spectator.detectChanges(); + }); + + it('should render the title it is given', () => { + expect(spectator.query(byTestId('asset-picker-title'))?.textContent?.trim()).toBe( + 'Add Image' + ); + }); + + it('should render the title the host passes on a later change', () => { + spectator.setInput('title', 'Add File'); + spectator.detectChanges(); + + expect(spectator.query(byTestId('asset-picker-title'))?.textContent?.trim()).toBe( + 'Add File' + ); + }); + + describe('fullscreen toggle', () => { + it('should ask the store to toggle when clicked', () => { + clickButton('asset-picker-fullscreen-btn'); + + 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 `