diff --git a/core-web/apps/dotcms-binary-field-builder/src/app/app.module.ts b/core-web/apps/dotcms-binary-field-builder/src/app/app.module.ts index b00d6ff9b6fb..4fecdd11a893 100644 --- a/core-web/apps/dotcms-binary-field-builder/src/app/app.module.ts +++ b/core-web/apps/dotcms-binary-field-builder/src/app/app.module.ts @@ -1,39 +1,57 @@ import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; +import { firstValueFrom } from 'rxjs'; -import { HttpClientModule } from '@angular/common/http'; -import { DoBootstrap, Injector, NgModule, Type } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { + DoBootstrap, + inject, + Injector, + NgModule, + provideAppInitializer, + Type +} from '@angular/core'; import { createCustomElement } from '@angular/elements'; import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideAnimations } from '@angular/platform-browser/animations'; -import { DotMessageService, DotUploadService } from '@dotcms/data-access'; -import { DotEditContentBinaryFieldComponent } from '@dotcms/edit-content'; +import { + DotMessageService, + DotUploadService, + DotWorkflowActionsFireService +} from '@dotcms/data-access'; +import { DotBinaryFieldCeBridgeComponent } from '@dotcms/edit-content'; import { provideDotCMSTheme } from '@dotcms/ui'; import { AppComponent } from './app.component'; interface ContenttypeFieldElement { tag: string; - component: Type; + component: Type; } const CONTENTTYPE_FIELDS: ContenttypeFieldElement[] = [ { tag: 'dotcms-binary-field', - component: DotEditContentBinaryFieldComponent + component: DotBinaryFieldCeBridgeComponent } ]; @NgModule({ declarations: [AppComponent], - imports: [ - BrowserModule, - BrowserAnimationsModule, - HttpClientModule, - DotEditContentBinaryFieldComponent, - MonacoEditorModule - ], - providers: [DotMessageService, DotUploadService, provideDotCMSTheme()] + imports: [BrowserModule, DotBinaryFieldCeBridgeComponent, MonacoEditorModule], + providers: [ + provideHttpClient(), + provideAnimations(), + DotMessageService, + DotUploadService, + DotWorkflowActionsFireService, + provideDotCMSTheme(), + provideAppInitializer(() => { + const dotMessageService = inject(DotMessageService); + + return firstValueFrom(dotMessageService.init()); + }) + ] }) export class AppModule implements DoBootstrap { constructor(private readonly injector: Injector) {} diff --git a/core-web/apps/dotcms-ui-e2e/.eslintrc.json b/core-web/apps/dotcms-ui-e2e/.eslintrc.json index e87ed63eca60..7364fef23c43 100644 --- a/core-web/apps/dotcms-ui-e2e/.eslintrc.json +++ b/core-web/apps/dotcms-ui-e2e/.eslintrc.json @@ -2,7 +2,12 @@ "extends": ["plugin:playwright/recommended", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*", "playwright-report/**", "test-results/**", "target/**"], "rules": { - "playwright/expect-expect": "off" + "playwright/expect-expect": "off", + "playwright/no-wait-for-timeout": "error", + "playwright/no-force-option": "error", + "playwright/no-skipped-test": "error", + "playwright/no-conditional-in-test": "error", + "playwright/prefer-locator": "error" }, "overrides": [ { diff --git a/core-web/apps/dotcms-ui-e2e/AGENTS.md b/core-web/apps/dotcms-ui-e2e/AGENTS.md index b799e5142bcb..f8139a7bd52d 100644 --- a/core-web/apps/dotcms-ui-e2e/AGENTS.md +++ b/core-web/apps/dotcms-ui-e2e/AGENTS.md @@ -21,7 +21,7 @@ playwright.config.ts ```bash pnpm nx e2e dotcms-ui-e2e --grep "pattern" HEADLESS=true pnpm nx e2e dotcms-ui-e2e --grep "pattern" -pnpm e2e:dev | e2e:dev:headless | e2e:ci | e2e:ui # from dotcms-ui-e2e/ +pnpm e2e:dev | e2e:dev:headless | e2e:ci | e2e:ui # from core-web/ npx playwright codegen http://localhost:4200/dotAdmin ``` @@ -48,7 +48,7 @@ Unsure? **Codegen first.** Avoid fragile `#dijit_*` IDs, CSS when `data-testid` - **Angular:** shell, edit form, dialogs — main `page`. - **Dojo:** content listing in `#detailFrame` — `getLegacyFrame(page)` from `@utils/iframe`. - **Nav:** `Portlet` from `@utils/portlets`; new content via `NewEditContentFormPage.goToNew()` (listing → New), not direct `/content/new/` URL. -- **Dojo menus:** `click({ force: true })` after `waitFor({ state: 'visible' })`. +- **Dojo menus:** wait for menu visibility, then click normally (no `force: true`). Field-specific selectors and flows: copy `tests/.../helpers/` and nearest `*.spec.ts` (e.g. `relationship-field/`). diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts index 8e32e1cc8487..aa327c8651c2 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/legacyEditContentForm.page.ts @@ -1,12 +1,14 @@ import { expect, type Frame, type Page } from '@playwright/test'; +import { clickAddNewContentFromList, goToContentList } from '@utils/contentListingNavigation'; -const LEGACY_EDIT_FRAME_URL_PATTERN = /edit_contentlet|portlet\/ext\/contentlet/; +const LEGACY_EDIT_FRAME_URL_PATTERN = /edit_contentlet/i; export class LegacyEditContentFormPage { constructor(private page: Page) {} /** - * Returns the legacy content edit iframe (edit_contentlet JSP), distinct from the shell detailFrame. + * Returns the legacy content edit iframe (edit_contentlet JSP) inside the PrimeNG dialog, + * not the Dojo listing frame (view_contentlets). */ async getLegacyContentFrame(): Promise { let frame: Frame | undefined; @@ -14,9 +16,11 @@ export class LegacyEditContentFormPage { await expect .poll( () => { - frame = this.page + const matchingFrames = this.page .frames() - .find((f) => LEGACY_EDIT_FRAME_URL_PATTERN.test(f.url())); + .filter((f) => LEGACY_EDIT_FRAME_URL_PATTERN.test(f.url())); + + frame = matchingFrames.at(-1); return frame; }, { timeout: 20000 } @@ -31,12 +35,11 @@ export class LegacyEditContentFormPage { } /** - * Navigates to create new content in the legacy editor (Dojo portlet inside detailFrame). - * URL: /dotAdmin/#/c/content/new/{contentTypeVariable} + * Navigates to create new content in the legacy editor (Dojo listing → Add New Content). */ async goToLegacyNew(contentTypeVariable: string) { - await this.page.goto(`/dotAdmin/#/c/content/new/${contentTypeVariable}`); - await this.page.waitForLoadState('domcontentloaded'); + await goToContentList(this.page, contentTypeVariable); + await clickAddNewContentFromList(this.page); const frame = await this.getLegacyContentFrame(); await frame.locator('dotcms-binary-field').first().waitFor({ diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts index 17033fea054a..f4065ebd934d 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/newEditContentForm.page.ts @@ -1,6 +1,5 @@ import { expect, Page } from '@playwright/test'; -import { getLegacyFrame } from '@utils/iframe'; -import { Portlet } from '@utils/portlets'; +import { clickAddNewContentFromList, goToContentList } from '@utils/contentListingNavigation'; export class NewEditContentFormPage { constructor(private page: Page) {} @@ -10,8 +9,7 @@ export class NewEditContentFormPage { * URL: /dotAdmin/#/c/content?filter=ContentTypeName */ async goToContentList(contentTypeVariable: string) { - await this.page.goto(`${Portlet.Content}?filter=${contentTypeVariable}`); - await this.page.waitForLoadState('domcontentloaded'); + await goToContentList(this.page, contentTypeVariable); } /** @@ -19,25 +17,7 @@ export class NewEditContentFormPage { * dropdown and selects "Add New Content" to open the new content form. */ async clickNewContentFromList() { - const frame = getLegacyFrame(this.page); - - // Wait for the Dojo iframe to fully load and widgets to initialize - await frame - .locator('.dijitDropDownButton') - .first() - .waitFor({ state: 'visible', timeout: 15000 }); - // Small delay for Dojo widget initialization after DOM is visible - await this.page.waitForTimeout(500); - - // Click the Dojo "+" dropdown button - const addButton = frame.locator('.dijitDropDownButton [role="button"]').first(); - await addButton.click(); - - // The dropdown menu renders inside the iframe. - // Use force:true because Dojo menus can flicker during animation. - const addNewOption = frame.locator('.dijitMenuItemLabel', { hasText: 'Add New Content' }); - await addNewOption.waitFor({ state: 'visible', timeout: 10000 }); - await addNewOption.click({ force: true }); + await clickAddNewContentFromList(this.page); // Wait for the Angular form to render (replaces networkidle which is unreliable in SPAs) await this.page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 }); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-search/helpers/content-listing.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-search/helpers/content-listing.ts index 82fe274cda8c..c29dc3f4fde1 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-search/helpers/content-listing.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-search/helpers/content-listing.ts @@ -89,6 +89,11 @@ export class ContentListingHelper { /** * Clicks the "createOptions" gear button and then "Show Query". + * Waits for #queryResults to confirm the modal fully rendered. + * + * The "Show Query" menu item lives in a Dojo popup that is always in the DOM + * but hidden until the button is clicked. In CI the popup can take longer to + * transition to visible, so we allow up to 15 s before giving up. */ async openQueryModal() { const optionsBtn = this.frame.getByRole('button', { name: 'createOptions' }); @@ -96,15 +101,26 @@ export class ContentListingHelper { await optionsBtn.click(); const showQueryLink = this.frame.getByText('Show Query'); - await showQueryLink.waitFor({ state: 'visible', timeout: 5000 }); + await showQueryLink.waitFor({ state: 'visible', timeout: 15000 }); await showQueryLink.click(); + + await this.frame.locator('#queryResults').waitFor({ state: 'visible', timeout: 15000 }); } /** - * Opens the add-content dropdown (Dojo). Use `force: true` to handle animation flicker. + * Opens the add-content dropdown (Dojo). */ async openAddContentDropdown() { await this.addDropdownButton.waitFor({ state: 'visible', timeout: 10000 }); await this.addDropdownButton.click(); } + + /** + * Returns the "API" span element inside the query modal. + * The element is a — not an tag — and calls + * window.open() via an async AJAX POST when clicked. + */ + get queryModalApiLink() { + return this.frame.locator('.dot-api-link'); + } } diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-search/portlet-integrity.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-search/portlet-integrity.spec.ts index b20e345c6d0a..ce07c7552a54 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-search/portlet-integrity.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-search/portlet-integrity.spec.ts @@ -57,17 +57,30 @@ test.describe('Content listing portlet — UI integrity', () => { await listing.goto(); await listing.openQueryModal(); + // openQueryModal already waits for #queryResults, this just confirms it await expect(listing.frame.locator('#queryResults')).toBeVisible(); }); - test.skip('API link in query modal opens new tab', async ({ page }) => { + test('API link in query modal opens new tab @smoke', async ({ page }) => { const listing = new ContentListingHelper(page); await listing.goto(); await listing.openQueryModal(); - const newTabPromise = page.waitForEvent('popup'); - await listing.frame.getByText('API', { exact: true }).click(); - const newTab = await newTabPromise; + // The "API" span calls window.open() inside a Dojo XHR callback — browsers block + // that as a popup because it is no longer a direct user gesture. Intercept the + // POST so it resolves instantly, keeping window.open() close enough to the click + // for Playwright (popup blocking disabled) to capture it reliably. + await page.route('**/api/content/_search', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ contentlets: [], total: 0 }) + }) + ); + + const newPagePromise = page.context().waitForEvent('page'); + await listing.queryModalApiLink.click(); + const newTab = await newPagePromise; await newTab.waitForLoadState(); expect(newTab.url()).toBeTruthy(); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts deleted file mode 100644 index cf10a1fc1593..000000000000 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/binary-field.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { expect, type Locator, type Page } from '@playwright/test'; - -import { - E2E_IMPORT_URL, - REQUIRED_FIELD_ERROR, - createTestPngFile, - createTestTextFile -} from '../../helpers/file-test-data'; - -export { E2E_IMPORT_URL, createTestPngFile, createTestTextFile }; - -const AI_DISABLED_TOOLTIP = 'Please configure dotAI to enable this feature'; - -/** - * Locator wrapper for the Binary field (`dot-binary-field-wrapper` / `dot-edit-content-binary-field`). - * Scopes interactions to `data-testid="field-{variable}"`. - */ -export class BinaryField { - readonly root: Locator; - readonly dropzone: Locator; - readonly fileInput: Locator; - readonly chooseFileBtn: Locator; - readonly importFromUrlBtn: Locator; - readonly createNewFileBtn: Locator; - readonly generateWithAiBtn: Locator; - readonly preview: Locator; - readonly requiredError: Locator; - readonly editButton: Locator; - readonly editButtonResponsive: Locator; - - constructor( - private page: Page, - readonly fieldVariable = 'binaryField' - ) { - this.root = page.getByTestId(`field-${fieldVariable}`); - this.dropzone = this.root.getByTestId('dropzone'); - this.fileInput = this.root.getByTestId('binary-field__file-input'); - this.chooseFileBtn = this.root.getByTestId('choose-file-btn'); - this.importFromUrlBtn = this.root.getByTestId('action-url-btn'); - this.createNewFileBtn = this.root.getByTestId('action-editor-btn'); - this.generateWithAiBtn = this.root.getByTestId('action-ai-btn'); - this.preview = this.root.getByTestId('preview'); - this.requiredError = this.root.locator('.error-message small'); - this.editButton = this.root.getByTestId('edit-button'); - this.editButtonResponsive = this.root.getByTestId('edit-button-responsive'); - } - - async expectVisible() { - await expect(this.dropzone).toBeVisible({ timeout: 15000 }); - } - - async uploadFile( - file: { name: string; mimeType: string; buffer: Buffer } = createTestTextFile() - ) { - const uploadResponse = this.page.waitForResponse( - (response) => - response.url().includes('/api/v1/temp') && - !response.url().includes('/byUrl') && - response.request().method() === 'POST' && - response.status() === 200 - ); - - await this.fileInput.setInputFiles(file); - await uploadResponse; - await this.expectPreviewVisible(); - } - - async uploadImage( - file: { name: string; mimeType: string; buffer: Buffer } = createTestPngFile() - ) { - const uploadResponse = this.page.waitForResponse( - (response) => - response.url().includes('/api/v1/temp') && - !response.url().includes('/byUrl') && - response.request().method() === 'POST' && - response.status() === 200 - ); - - await this.fileInput.setInputFiles(file); - await uploadResponse; - await this.expectPreviewVisible(); - } - - async expectPreviewVisible() { - await expect(this.preview).toBeVisible({ timeout: 15000 }); - } - - async expectPreviewShowsFileName(fileName: string) { - await expect(this.preview.locator('.preview-metadata_header')).toContainText(fileName, { - timeout: 15000 - }); - } - - async expectPreviewShowsContent(text: string) { - await expect(this.preview.getByTestId('code-preview')).toContainText(text, { - timeout: 15000 - }); - } - - async expectRequiredErrorVisible() { - await expect(this.requiredError).toBeVisible({ timeout: 10000 }); - await expect(this.requiredError).toHaveText(REQUIRED_FIELD_ERROR); - } - - async openImportFromUrlDialog() { - await this.importFromUrlBtn.getByRole('button').click(); - const dialog = this.page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 10000 }); - await expect(dialog).toContainText('URL'); - return dialog; - } - - getImportDialogLocators() { - const dialog = this.page.getByRole('dialog'); - return { - dialog, - urlMode: dialog.getByTestId('url-mode'), - urlInput: dialog.getByTestId('url-input'), - cancelButton: dialog.getByTestId('cancel-button'), - importButton: dialog.getByTestId('import-button') - }; - } - - async importFromUrl(url: string) { - await this.openImportFromUrlDialog(); - const { urlInput, importButton } = this.getImportDialogLocators(); - - const byUrlResponse = this.page.waitForResponse( - (response) => - response.url().includes('/api/v1/temp/byUrl') && - response.request().method() === 'POST', - { timeout: 30000 } - ); - - await urlInput.fill(url); - await importButton.getByRole('button').click(); - - const response = await byUrlResponse; - expect(response.ok()).toBeTruthy(); - - await this.expectPreviewVisible(); - } - - async expectAiButtonDisabledWithTooltip() { - const aiButton = this.generateWithAiBtn.getByRole('button'); - await expect(aiButton).toBeDisabled(); - - await aiButton.hover({ force: true }); - await expect(this.page.getByText(AI_DISABLED_TOOLTIP)).toBeVisible({ timeout: 10000 }); - } - - async isAiButtonEnabled(): Promise { - return this.generateWithAiBtn.getByRole('button').isEnabled(); - } - - async expectEditButtonVisible() { - await expect(this.editButton.or(this.editButtonResponsive).first()).toBeVisible({ - timeout: 15000 - }); - } - - async clickEditImage() { - await this.editButton.or(this.editButtonResponsive).first().click(); - } - - /** New editor: PrimeNG dialog + standalone JSP iframe */ - async expectNewEditorImageEditorOpen() { - const dialog = this.page.getByRole('dialog'); - await expect(dialog).toBeVisible({ timeout: 15000 }); - - const iframe = dialog.getByTestId('legacy-image-editor-iframe'); - await expect(iframe).toBeVisible(); - - const editorFrame = this.page.frameLocator('[data-testid="legacy-image-editor-iframe"]'); - await expect(editorFrame.locator('#dotImageDialog, #imageToolIframe').first()).toBeVisible({ - timeout: 30000 - }); - } - - async openImageEditorInNewEditor() { - await this.expectEditButtonVisible(); - await this.clickEditImage(); - await this.expectNewEditorImageEditorOpen(); - } -} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field-image-editor.spec.ts similarity index 95% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field-image-editor.spec.ts index c96621246007..f43fb40d6e93 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field-image-editor.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field-image-editor.spec.ts @@ -56,6 +56,8 @@ test.describe('Binary field image editor — new editor', () => { } }); + // The unified Binary field shows "Edit image" for image files. The new + // editor opens the legacy image editor JSP inside a PrimeNG dialog iframe. test('import image and Edit opens legacy image editor dialog @critical', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field.spec.ts similarity index 56% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field.spec.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field.spec.ts index 70713e8648f2..6fc05782213c 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/binary-field.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/binary-field.spec.ts @@ -89,26 +89,83 @@ test('import from URL completes without 400 and shows preview', async ({ page }) await field.importFromUrl(E2E_IMPORT_URL); }); -test('required empty binary field shows error helper text on save', async ({ page, request }) => { - if (contentType) { - await deleteContentType(request, contentType.id); - contentType = null; - } +test.describe('required binary field', () => { + let requiredContentType: ContentType; + let requiredContentTypeVariable: string; - contentType = await createBinaryFieldContentType(request, { required: true }); - contentTypeVariable = contentType.variable; + test.beforeEach(async ({ request }) => { + requiredContentType = await createBinaryFieldContentType(request, { required: true }); + requiredContentTypeVariable = requiredContentType.variable; + }); + + test.afterEach(async ({ request }) => { + await deleteContentType(request, requiredContentType.id); + }); + + test('required empty binary field shows error helper text on save', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(requiredContentTypeVariable); + + const field = new BinaryField(page, BINARY_FIELD_VARIABLE); + await field.expectVisible(); + + await formPage.fillTextField(`E2E Required Binary ${faker.lorem.word()}`); + await page.getByRole('button', { name: 'Save' }).click(); + + await field.expectRequiredErrorVisible(); + await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + }); +}); + +test('upload text file does not show Edit image button', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BinaryField(page, BINARY_FIELD_VARIABLE); + await field.expectVisible(); + await field.uploadFile(TEST_FILE); + await field.expectEditButtonHidden(); +}); + +test('remove file shows confirm popup and clears preview', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new BinaryField(page, BINARY_FIELD_VARIABLE); + await field.expectVisible(); + await field.uploadFile(TEST_FILE); + await field.expectPreviewVisible(); + await field.clickRemoveButton(); + await field.confirmRemoveInPopup(); + await field.expectPreviewHidden(); +}); + +test('edit existing binary contentlet shows preview without server error', async ({ page }) => { + const title = `E2E Binary Image Hydration ${faker.lorem.word()}`; const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); const field = new BinaryField(page, BINARY_FIELD_VARIABLE); await field.expectVisible(); + await field.importFromUrl(E2E_IMPORT_URL); + + await formPage.fillTextField(title); + await formPage.save(); - await formPage.fillTextField(`E2E Required Binary ${faker.lorem.word()}`); - await page.getByRole('button', { name: 'Save' }).click(); + await page.waitForURL(/\/content\/([a-f0-9-]+)/); + const [, savedContentIdentifier] = page + .url() + .match(/\/content\/([a-f0-9-]+)/) as RegExpMatchArray; + expect(savedContentIdentifier).toBeTruthy(); + + await page.goto(`/dotAdmin/#/content/${savedContentIdentifier}`); + await page.waitForLoadState('domcontentloaded'); + await page.getByTestId('title').waitFor({ state: 'visible', timeout: 15000 }); - await field.expectRequiredErrorVisible(); - await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + await field.expectPreviewVisible(); + await field.expectNoServerErrorMessage(); + await field.expectThumbnailVisible(); }); test('disabled Generate With dotAI button shows tooltip when AI plugin not installed', async ({ @@ -119,9 +176,5 @@ test('disabled Generate With dotAI button shows tooltip when AI plugin not insta const field = new BinaryField(page, BINARY_FIELD_VARIABLE); await field.expectVisible(); - - const aiEnabled = await field.isAiButtonEnabled(); - test.skip(aiEnabled, 'dotAI plugin is installed — disabled-tooltip case does not apply'); - - await field.expectAiButtonDisabledWithTooltip(); + await field.expectAiButtonDisabledWithTooltipWhenApplicable(); }); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/binary-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/binary-field.ts new file mode 100644 index 000000000000..6ee2601f43db --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/binary-field.ts @@ -0,0 +1,120 @@ +import { expect, type Page } from '@playwright/test'; + +import { FileField } from '../../file-field/helpers/file-field'; +import { + E2E_IMPORT_URL, + createTestPngFile, + createTestTextFile +} from '../../helpers/file-test-data'; + +export { E2E_IMPORT_URL, createTestPngFile, createTestTextFile }; + +const AI_DISABLED_TOOLTIP = 'Please configure dotAI to enable this feature'; + +/** + * Locator wrapper for the Binary field rendered by the unified + * `dot-edit-content-file-field` component. Scopes interactions to + * `data-testid="field-{variable}"`. + */ +export class BinaryField extends FileField { + constructor(page: Page, fieldVariable = 'binaryField') { + super(page, fieldVariable); + } + + override async uploadFile( + file: { name: string; mimeType: string; buffer: Buffer } = createTestTextFile() + ) { + const uploadResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/temp') && + !response.url().includes('/byUrl') && + response.request().method() === 'POST' && + response.status() === 200 + ); + + await this.fileInput.setInputFiles(file); + await uploadResponse; + await this.expectPreviewVisible(); + } + + async uploadImage( + file: { name: string; mimeType: string; buffer: Buffer } = createTestPngFile() + ) { + const uploadResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/temp') && + !response.url().includes('/byUrl') && + response.request().method() === 'POST' && + response.status() === 200 + ); + + await this.fileInput.setInputFiles(file); + await uploadResponse; + await this.expectPreviewVisible(); + } + + override async importFromUrl(url: string) { + await this.openImportFromUrlDialog(); + const { urlInput, importButton } = this.getImportDialogLocators(); + + const byUrlResponse = this.page.waitForResponse( + (response) => + response.url().includes('/api/v1/temp/byUrl') && + response.request().method() === 'POST', + { timeout: 30000 } + ); + + await urlInput.fill(url); + await importButton.getByRole('button').click(); + + const response = await byUrlResponse; + expect(response.ok()).toBeTruthy(); + + await this.expectPreviewVisible(); + } + + /** + * Asserts the disabled dotAI tooltip when the plugin is not installed. + * No-op when dotAI is configured (button enabled) — that environment is valid but out of scope for this case. + */ + async expectAiButtonDisabledWithTooltipWhenApplicable() { + const aiButton = this.generateWithAiBtn.getByRole('button'); + + if (await aiButton.isEnabled()) { + return; + } + + await expect(aiButton).toBeDisabled(); + + // Hover the p-button host (pointer-events-auto) — disabled inner buttons ignore hover. + await this.generateWithAiBtn.hover(); + await expect(this.page.getByText(AI_DISABLED_TOOLTIP)).toBeVisible({ timeout: 10000 }); + } + + async clickEditImage() { + await this.editButton.or(this.editButtonResponsive).first().click(); + } + + /** + * New editor: the legacy image editor JSP is embedded in a PrimeNG dialog + * iframe (`dot-legacy-image-editor-dialog`), not the Dojo `#dotImageDialog`. + */ + async expectImageEditorOpen() { + const dialog = this.page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 15000 }); + + const iframe = dialog.getByTestId('legacy-image-editor-iframe'); + await expect(iframe).toBeVisible({ timeout: 30000 }); + + const editorFrame = this.page.frameLocator('[data-testid="legacy-image-editor-iframe"]'); + await expect(editorFrame.locator('#dotImageDialog, #imageToolIframe').first()).toBeVisible({ + timeout: 30000 + }); + } + + async openImageEditorInNewEditor() { + await this.expectEditButtonVisible(); + await this.clickEditImage(); + await this.expectImageEditorOpen(); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/legacy-binary-field.ts similarity index 95% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/legacy-binary-field.ts index 82824e2ae4b8..b041b3044f67 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/binary-field/helpers/legacy-binary-field.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/binary-field/helpers/legacy-binary-field.ts @@ -24,8 +24,8 @@ export class LegacyBinaryField { ) { this.root = frame.locator(`#binary-field-${fieldVariable}, dotcms-binary-field`); this.dropzone = this.root.getByTestId('dropzone'); - this.fileInput = this.root.getByTestId('binary-field__file-input'); - this.importFromUrlBtn = this.root.getByTestId('action-url-btn'); + this.fileInput = this.root.getByTestId('file-field__file-input'); + this.importFromUrlBtn = this.root.getByTestId('action-import-from-url'); this.preview = this.root.getByTestId('preview'); this.editButton = this.root.getByTestId('edit-button'); this.editButtonResponsive = this.root.getByTestId('edit-button-responsive'); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 similarity index 76% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-field/file-field.spec.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/file-field.spec.ts index 38289625d82d..17f78daaf6b5 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 @@ -107,24 +107,40 @@ test('import from URL completes without 400 and shows preview @critical', async await field.importFromUrl(E2E_IMPORT_URL); }); -test('required empty file field shows error helper text on save', async ({ page, request }) => { - if (contentType) { - await deleteContentType(request, contentType.id); - contentType = null; - } - - contentType = await createFileFieldContentType(request, { required: true }); - contentTypeVariable = contentType.variable; - +test('import image URL does not show Edit image button', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); const field = new FileField(page, FILE_FIELD_VARIABLE); await field.expectVisible(); + await field.importFromUrl(E2E_IMPORT_URL); + await field.expectEditButtonHidden(); +}); - await formPage.fillTextField(`E2E Required File ${faker.lorem.word()}`); - await page.getByRole('button', { name: 'Save' }).click(); +test.describe('required file field', () => { + let requiredContentType: ContentType; + let requiredContentTypeVariable: string; - await field.expectRequiredErrorVisible(); - await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + test.beforeEach(async ({ request }) => { + requiredContentType = await createFileFieldContentType(request, { required: true }); + requiredContentTypeVariable = requiredContentType.variable; + }); + + test.afterEach(async ({ request }) => { + await deleteContentType(request, requiredContentType.id); + }); + + test('required empty file field shows error helper text on save', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(requiredContentTypeVariable); + + const field = new FileField(page, FILE_FIELD_VARIABLE); + await field.expectVisible(); + + await formPage.fillTextField(`E2E Required File ${faker.lorem.word()}`); + await page.getByRole('button', { name: 'Save' }).click(); + + await field.expectRequiredErrorVisible(); + await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + }); }); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 similarity index 74% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-field/helpers/file-field.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/file-field/helpers/file-field.ts index 61cefa68043f..a9757f7bbf2b 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 @@ -21,6 +21,11 @@ export class FileField { readonly selectExistingFileBtn: Locator; readonly createNewFileBtn: Locator; readonly generateWithAiBtn: Locator; + readonly preview: Locator; + readonly editButton: Locator; + readonly editButtonResponsive: Locator; + readonly removeButton: Locator; + readonly removeButtonResponsive: Locator; readonly requiredError: Locator; constructor( @@ -35,6 +40,11 @@ export class FileField { this.selectExistingFileBtn = this.root.getByTestId('action-existing-file'); this.createNewFileBtn = this.root.getByTestId('action-new-file'); this.generateWithAiBtn = this.root.getByTestId('action-generate-with-ai'); + this.preview = this.root.getByTestId('preview'); + this.editButton = this.root.getByTestId('edit-button'); + this.editButtonResponsive = this.root.getByTestId('edit-button-responsive'); + this.removeButton = this.root.getByTestId('remove-button'); + this.removeButtonResponsive = this.root.getByTestId('remove-button-responsive'); this.requiredError = this.root.locator('.error-message small'); } @@ -58,12 +68,42 @@ export class FileField { } async expectPreviewVisible() { - const preview = this.root - .getByTestId('code-preview') - .or(this.root.getByTestId('metadata-title')) - .or(this.root.getByTestId('contentlet-thumbnail')) - .or(this.root.getByTestId('temp-file-thumbnail')); - await expect(preview.first()).toBeVisible({ timeout: 15000 }); + await expect(this.preview).toBeVisible({ timeout: 15000 }); + } + + async expectPreviewHidden() { + await expect(this.preview).toBeHidden({ timeout: 15000 }); + await expect(this.dropzone).toBeVisible({ timeout: 15000 }); + } + + /** + * The "Edit image" action is only available for Binary fields with an image file. + */ + async expectEditButtonVisible() { + await expect(this.editButton.or(this.editButtonResponsive).first()).toBeVisible({ + timeout: 15000 + }); + } + + async expectEditButtonHidden() { + await this.expectPreviewVisible(); + await expect(this.editButton).toBeHidden(); + await expect(this.editButtonResponsive).toBeHidden(); + } + + async clickRemoveButton() { + await this.removeButton.or(this.removeButtonResponsive).first().click(); + } + + async confirmRemoveInPopup() { + const popup = this.page.locator('.p-confirmpopup'); + await expect(popup).toBeVisible({ timeout: 10000 }); + await expect(popup).toContainText('Are you sure you want to remove this file?'); + await popup.getByRole('button', { name: 'Remove' }).click(); + } + + async expectNoServerErrorMessage() { + await expect(this.root.getByText('Something went wrong')).toBeHidden(); } async expectPreviewShowsFileName(fileName: string) { diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/helpers/file-test-data.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/helpers/file-test-data.ts similarity index 100% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/helpers/file-test-data.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/helpers/file-test-data.ts diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/image-field/helpers/image-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/helpers/image-field.ts similarity index 100% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/image-field/helpers/image-field.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/helpers/image-field.ts diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 similarity index 73% rename from core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/image-field/image-field.spec.ts rename to core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/file-upload-fields/image-field/image-field.spec.ts index 5ffde1738dbd..328187f2d233 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/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 @@ -81,26 +81,42 @@ test('upload an image, save, reload, and thumbnail still displayed @critical', a await field.expectPreviewShowsFileName(TEST_IMAGE.name); }); -test('required empty image field shows error helper text on save', async ({ page, request }) => { - if (contentType) { - await deleteContentType(request, contentType.id); - contentType = null; - } - - contentType = await createImageFieldContentType(request, { required: true }); - contentTypeVariable = contentType.variable; - +test('upload image does not show Edit image button', async ({ page }) => { const formPage = new NewEditContentFormPage(page); await formPage.goToNew(contentTypeVariable); const field = new ImageField(page, IMAGE_FIELD_VARIABLE); await field.expectVisible(); + await field.uploadFile(TEST_IMAGE); + await field.expectEditButtonHidden(); +}); - await formPage.fillTextField(`E2E Required Image ${faker.lorem.word()}`); - await page.getByRole('button', { name: 'Save' }).click(); +test.describe('required image field', () => { + let requiredContentType: ContentType; + let requiredContentTypeVariable: string; - await field.expectRequiredErrorVisible(); - await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + test.beforeEach(async ({ request }) => { + requiredContentType = await createImageFieldContentType(request, { required: true }); + requiredContentTypeVariable = requiredContentType.variable; + }); + + test.afterEach(async ({ request }) => { + await deleteContentType(request, requiredContentType.id); + }); + + test('required empty image field shows error helper text on save', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(requiredContentTypeVariable); + + const field = new ImageField(page, IMAGE_FIELD_VARIABLE); + await field.expectVisible(); + + await formPage.fillTextField(`E2E Required Image ${faker.lorem.word()}`); + await page.getByRole('button', { name: 'Save' }).click(); + + await field.expectRequiredErrorVisible(); + await expect(page).not.toHaveURL(/\/content\/[a-f0-9-]+/); + }); }); test('image field shows Generate With dotAI and hides Create New File @smoke', async ({ page }) => { diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/relationship-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/relationship-field.ts index 38e271d41f47..a9503065f392 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/relationship-field.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/relationship-field.ts @@ -151,6 +151,53 @@ export class RelationshipField { return text?.trim() ?? ''; } + /** + * Returns normalized header labels from the relationship table. + */ + async getHeaderTexts(): Promise { + const headers = this.table.locator('thead th'); + const headerCount = await headers.count(); + const headerTexts: string[] = []; + + for (let i = 0; i < headerCount; i++) { + const text = await headers.nth(i).textContent(); + const trimmed = text?.trim(); + if (trimmed) { + headerTexts.push(trimmed.toLowerCase()); + } + } + + return headerTexts; + } + + /** + * Drags a row from sourceIndex to targetIndex using drag handles. + */ + async dragRowToPosition(sourceIndex: number, targetIndex: number): Promise { + const handles = this.getDragHandles(); + const sourceHandle = handles.nth(sourceIndex); + const targetHandle = handles.nth(targetIndex); + + const sourceBounds = await sourceHandle.boundingBox(); + const targetBounds = await targetHandle.boundingBox(); + + if (!sourceBounds || !targetBounds) { + throw new Error('Could not get bounding boxes for drag handles'); + } + + await this.page.mouse.move( + sourceBounds.x + sourceBounds.width / 2, + sourceBounds.y + sourceBounds.height / 2 + ); + await this.page.mouse.down(); + await this.page.mouse.move( + targetBounds.x + targetBounds.width / 2, + targetBounds.y + targetBounds.height / 2, + { steps: 10 } + ); + await this.page.mouse.up(); + } + /** * Clicks the delete button on a specific row. */ diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/select-existing-content-dialog.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/select-existing-content-dialog.ts index 63c41160f2ee..9ddbddfd4831 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/select-existing-content-dialog.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/helpers/select-existing-content-dialog.ts @@ -206,7 +206,21 @@ export class SelectExistingContentDialog { await expect(row).toHaveClass(/opacity-50/); await expect(row).toHaveClass(/pointer-events-none/); - // Checkbox or radio should be disabled + const checkbox = row.getByTestId('row-checkbox'); + const radio = row.getByTestId('row-radio'); + const control = (await checkbox.count()) > 0 ? checkbox : radio; + await expect(control.locator('.p-disabled')).toBeVisible(); + } + + /** + * Asserts the row containing the given text is constrained. + */ + async expectRowConstrainedByText(text: string): Promise { + const row = this.rows.filter({ hasText: text }); + await expect(row).toHaveCount(1); + await expect(row).toHaveClass(/opacity-50/); + await expect(row).toHaveClass(/pointer-events-none/); + const checkbox = row.getByTestId('row-checkbox'); const radio = row.getByTestId('row-radio'); const control = (await checkbox.count()) > 0 ? checkbox : radio; @@ -221,6 +235,15 @@ export class SelectExistingContentDialog { await expect(row).not.toHaveClass(/opacity-50/); } + /** + * Asserts the row containing the given text is selectable. + */ + async expectRowSelectableByText(text: string): Promise { + const row = this.rows.filter({ hasText: text }); + await expect(row).toHaveCount(1); + await expect(row).not.toHaveClass(/opacity-50/); + } + // ─── Error State ───────────────────────────────────────────────── async expectErrorMessage(): Promise { diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-advanced.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-advanced.spec.ts index 4ff945d203a0..8e51a8fcf8bd 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-advanced.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-advanced.spec.ts @@ -248,17 +248,7 @@ test.describe('Custom Columns (showFields)', () => { await relationshipField.expectRowCount(2); // With showFields="title,bio", headers should include Title and Bio - const table = relationshipField.table; - const headers = table.locator('thead th'); - - const headerTexts: string[] = []; - const headerCount = await headers.count(); - for (let i = 0; i < headerCount; i++) { - const text = await headers.nth(i).textContent(); - if (text?.trim()) { - headerTexts.push(text.trim().toLowerCase()); - } - } + const headerTexts = await relationshipField.getHeaderTexts(); expect(headerTexts.some((h) => h.includes('title'))).toBe(true); expect(headerTexts.some((h) => h.includes('bio'))).toBe(true); @@ -289,17 +279,8 @@ test.describe('Custom Columns (showFields)', () => { await formPage.goToContent(blog.inode); // Default columns: Title, Language, Status - const table = adminPage.getByTestId('relationship-field-table'); - const headers = table.locator('thead th'); - - const headerTexts: string[] = []; - const headerCount = await headers.count(); - for (let i = 0; i < headerCount; i++) { - const text = await headers.nth(i).textContent(); - if (text?.trim()) { - headerTexts.push(text.trim().toLowerCase()); - } - } + const relationshipField = new RelationshipField(adminPage); + const headerTexts = await relationshipField.getHeaderTexts(); expect(headerTexts.some((h) => h.includes('title'))).toBe(true); expect(headerTexts.some((h) => h.includes('language'))).toBe(true); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-cardinality.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-cardinality.spec.ts index 531ef6f078db..636387f6d92b 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-cardinality.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-cardinality.spec.ts @@ -5,7 +5,6 @@ import { SelectExistingContentDialog } from './helpers/select-existing-content-d import { CARDINALITY, - expect, test, type TestContentType, type TestContentlet @@ -111,22 +110,8 @@ test.describe('Cardinality Constraints', () => { await dialog.expectRowCount(2); - let constrainedIdx = -1; - let freeIdx = -1; - - for (let i = 0; i < 2; i++) { - const rowText = await dialog.rows.nth(i).textContent(); - if (rowText?.includes('Comment 1')) { - constrainedIdx = i; - } else { - freeIdx = i; - } - } - - expect(constrainedIdx, 'Comment 1 should exist in dialog').toBeGreaterThanOrEqual(0); - - await dialog.expectRowConstrained(constrainedIdx); - await dialog.expectRowSelectable(freeIdx); + await dialog.expectRowConstrainedByText('Comment 1'); + await dialog.expectRowSelectableByText('Comment 2'); }); }); @@ -226,25 +211,8 @@ test.describe('Cardinality Constraints', () => { await dialog.expectRowCount(2); - let constrainedIdx = -1; - let freeIdx = -1; - - for (let i = 0; i < 2; i++) { - const rowText = await dialog.rows.nth(i).textContent(); - if (rowText?.includes('Child 1')) { - constrainedIdx = i; - } else { - freeIdx = i; - } - } - - expect(constrainedIdx, 'Child 1 should exist in dialog').toBeGreaterThanOrEqual(0); - - // Child 1 disabled — already related to Parent A - await dialog.expectRowConstrained(constrainedIdx); - - // Child 2 selectable — free - await dialog.expectRowSelectable(freeIdx); + await dialog.expectRowConstrainedByText('Child 1'); + await dialog.expectRowSelectableByText('Child 2'); }); test('child already related appears disabled when creating new parent @critical', async ({ @@ -263,25 +231,8 @@ test.describe('Cardinality Constraints', () => { await dialog.expectRowCount(2); - let constrainedIdx = -1; - let freeIdx = -1; - - for (let i = 0; i < 2; i++) { - const rowText = await dialog.rows.nth(i).textContent(); - if (rowText?.includes('Child 1')) { - constrainedIdx = i; - } else { - freeIdx = i; - } - } - - expect(constrainedIdx, 'Child 1 should exist in dialog').toBeGreaterThanOrEqual(0); - - // Child 1 disabled — already related to Parent A - await dialog.expectRowConstrained(constrainedIdx); - - // Child 2 selectable — free - await dialog.expectRowSelectable(freeIdx); + await dialog.expectRowConstrainedByText('Child 1'); + await dialog.expectRowSelectableByText('Child 2'); }); }); }); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-table.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-table.spec.ts index 73ded36458a0..e5895b4a95f7 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-table.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/relationship-field/relationship-field-table.spec.ts @@ -68,28 +68,7 @@ test.describe('Reorder (Drag & Drop)', () => { const originalThird = await relationshipField.getRowTitle(2); // Drag row 3 to row 1 position - const handles = relationshipField.getDragHandles(); - const sourceHandle = handles.nth(2); - const targetHandle = handles.nth(0); - - const sourceBounds = await sourceHandle.boundingBox(); - const targetBounds = await targetHandle.boundingBox(); - - if (!sourceBounds || !targetBounds) { - throw new Error('Could not get bounding boxes for drag handles'); - } - - await adminPage.mouse.move( - sourceBounds.x + sourceBounds.width / 2, - sourceBounds.y + sourceBounds.height / 2 - ); - await adminPage.mouse.down(); - await adminPage.mouse.move( - targetBounds.x + targetBounds.width / 2, - targetBounds.y + targetBounds.height / 2, - { steps: 10 } - ); - await adminPage.mouse.up(); + await relationshipField.dragRowToPosition(2, 0); // Verify order changed: original 3rd should now be 1st const newFirst = await relationshipField.getRowTitle(0); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/login/translations.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/login/translations.spec.ts index 6f550bb58e86..5c0bac6ff133 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/login/translations.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/login/translations.spec.ts @@ -1,8 +1,6 @@ import { expect, test } from '@playwright/test'; import { waitForVisibleAndCallback } from '@utils/utils'; -import { assert } from 'console'; - const languages = [ { language: 'español (España)', translation: '¡Bienvenido!' }, { language: 'italiano (Italia)', translation: 'Benvenuto!' }, @@ -27,10 +25,9 @@ languages.forEach((list) => { dropdownTriggerLocator.click() ); - const pageByTextLocator = page.getByText(language); - await waitForVisibleAndCallback(pageByTextLocator, () => pageByTextLocator.click()); + const languageOption = page.getByText(language); + await waitForVisibleAndCallback(languageOption, () => languageOption.click()); - // Assertion of the translation - assert(await expect(page.getByTestId('header')).toContainText(translation)); + await expect(page.getByTestId('header')).toContainText(translation); }); }); diff --git a/core-web/apps/dotcms-ui-e2e/src/utils/contentListingNavigation.ts b/core-web/apps/dotcms-ui-e2e/src/utils/contentListingNavigation.ts new file mode 100644 index 000000000000..7dbc56919889 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/utils/contentListingNavigation.ts @@ -0,0 +1,64 @@ +import { expect, type Page } from '@playwright/test'; +import { getLegacyFrame } from '@utils/iframe'; +import { Portlet } from '@utils/portlets'; + +/** True when the shell is on an Angular content route (edit/new), not the Dojo listing. */ +export function isOnAngularContentRoute(page: Page): boolean { + const url = page.url(); + + return /#\/content\//.test(url) && !/#\/c\/content/.test(url); +} + +/** Waits for the Dojo content listing iframe and its widgets to be ready. */ +export async function waitForContentListingReady(page: Page) { + const frame = getLegacyFrame(page); + + await frame + .locator('.dijitDropDownButton') + .first() + .waitFor({ state: 'visible', timeout: 20000 }); + + await frame + .locator('dot-data-view-button.hydrated') + .waitFor({ state: 'visible', timeout: 20000 }); +} + +/** + * Navigates to the Content portlet filtered by content type. + * URL: /dotAdmin/#/c/content?filter={contentTypeVariable} + */ +export async function goToContentList(page: Page, contentTypeVariable: string) { + const listingUrl = `${Portlet.Content}?filter=${contentTypeVariable}`; + + // Hash-only navigation from Angular edit routes does not re-init the Dojo iframe. + if (isOnAngularContentRoute(page)) { + await page.goto('/dotAdmin/'); + await page.waitForLoadState('domcontentloaded'); + } + + await page.goto(listingUrl); + await page.waitForLoadState('domcontentloaded'); + await waitForContentListingReady(page); +} + +/** + * From the content listing (Dojo portlet inside detailFrame), opens the "+" + * dropdown and selects "Add New Content". + */ +export async function clickAddNewContentFromList(page: Page) { + const frame = getLegacyFrame(page); + + const addButton = frame.locator('.dijitDropDownButton [role="button"]').first(); + const addNewOption = frame.getByRole('menuitem', { name: 'Add New Content' }); + + await addButton.waitFor({ state: 'visible', timeout: 10000 }); + + // Open the dropdown and select the item atomically. The Dojo menu auto-closes + // and the listing portlet can re-render after a hash navigation, so retry the + // whole open+click rather than just the visibility check. + await expect(async () => { + await addButton.click(); + await expect(addNewOption).toBeVisible({ timeout: 2000 }); + await addNewOption.click({ timeout: 2000 }); + }).toPass({ timeout: 20000 }); +} diff --git a/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.spec.ts b/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.spec.ts index 030d06370191..50ac133bfdd7 100644 --- a/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -57,10 +57,10 @@ describe('DotMessageService', () => { }); describe('init', () => { - it('should call languages endpoint with default language and set them in local storage', () => { + it('should call languages endpoint with default language and set them in local storage', async () => { jest.spyOn(dotLocalstorageService, 'setItem'); jest.spyOn(dotLocalstorageService, 'getItem').mockReturnValue(null); - dotMessageService.init(); + await firstValueFrom(dotMessageService.init()); expect(http.get).toHaveBeenCalledWith('/api/v2/languages/default/keys'); expect(dotLocalstorageService.setItem).toHaveBeenCalledWith( 'dotMessagesKeys', @@ -77,25 +77,25 @@ describe('DotMessageService', () => { expect(http.get).toHaveBeenCalledWith('/api/v2/languages/default/keys'); }); - it('should call languages endpoint with passed language', () => { - dotMessageService.init({ language: 'en_US' }); + it('should call languages endpoint with passed language', async () => { + await firstValueFrom(dotMessageService.init({ language: 'en_US' })); expect(http.get).toHaveBeenCalledWith('/api/v2/languages/en_US/keys'); }); - it('should read messages from local storage', () => { + it('should read messages from local storage', async () => { jest.spyOn(dotLocalstorageService, 'getItem'); dotLocalstorageService.setItem(MESSAGES_LOCALSTORAGE_KEY, messages); dotLocalstorageService.setItem(LANGUAGE_LOCALSTORAGE_KEY, DEFAULT_LANG); dotLocalstorageService.setItem(BUILDATE_LOCALSTORAGE_KEY, '2020-01-01'); - dotMessageService.init(); + await firstValueFrom(dotMessageService.init()); expect(dotLocalstorageService.getItem).toHaveBeenCalledWith('dotMessagesKeys'); expect(http.get).not.toHaveBeenCalled(); }); }); describe('get', () => { - beforeEach(() => { - dotMessageService.init(); + beforeEach(async () => { + await firstValueFrom(dotMessageService.init()); }); it('should return message', () => { diff --git a/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.ts b/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.ts index c439aa498d25..06783cb2286b 100644 --- a/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.ts +++ b/core-web/libs/data-access/src/lib/dot-messages/dot-messages.service.ts @@ -1,7 +1,9 @@ +import { Observable, of } from 'rxjs'; + import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { map, take } from 'rxjs/operators'; +import { map, shareReplay, take, tap } from 'rxjs/operators'; import { formatMessage } from '@dotcms/utils'; @@ -29,13 +31,18 @@ export class DotMessageService { /** * Initializes the DotMessageService. * @param {DotMessageServiceParams} params - The parameters for initialization. - * @return {void} + * @return {Observable} Emits when messages are available (HTTP or localStorage). */ - init(params?: DotMessageServiceParams): void { + init(params?: DotMessageServiceParams): Observable { const lang = params?.language || DEFAULT_LANG; const buildDate = params?.buildDate || null; - this.getAll(lang, buildDate); + const load$ = this.getAll(lang, buildDate); + + // Backward compatibility for callers that ignore the returned observable. + load$.pipe(take(1)).subscribe(); + + return load$; } /** @@ -60,27 +67,32 @@ export class DotMessageService { * @param {string} lang - The language code for the messages. * @param newBuildDate * @private - * @returns {void} + * @returns {Observable} Emits when messages are available. */ - private getAll(lang: string = DEFAULT_LANG, newBuildDate: string | null = null): void { + private getAll( + lang: string = DEFAULT_LANG, + newBuildDate: string | null = null + ): Observable { if (this.shouldReloadMessages(lang, newBuildDate)) { - this.http - .get<{ entity: Record }>(this.geti18nURL(lang)) - .pipe( - take(1), - map((x) => x?.entity) - ) - .subscribe((messages) => { + return this.http.get<{ entity: Record }>(this.geti18nURL(lang)).pipe( + take(1), + map((x) => x?.entity), + tap((messages) => { this.messageMap = messages as { [key: string]: string }; this.dotLocalstorageService.setItem(MESSAGES_LOCALSTORAGE_KEY, this.messageMap); this.dotLocalstorageService.setItem(LANGUAGE_LOCALSTORAGE_KEY, lang); this.dotLocalstorageService.setItem(BUILDATE_LOCALSTORAGE_KEY, newBuildDate); - }); - } else { - this.messageMap = this.dotLocalstorageService.getItem(MESSAGES_LOCALSTORAGE_KEY) as { - [key: string]: string; - }; + }), + map(() => void 0), + shareReplay({ bufferSize: 1, refCount: false }) + ); } + + this.messageMap = this.dotLocalstorageService.getItem(MESSAGES_LOCALSTORAGE_KEY) as { + [key: string]: string; + }; + + return of(void 0); } /** diff --git a/core-web/libs/edit-content/src/index.ts b/core-web/libs/edit-content/src/index.ts index a15a1e4b60d2..9916eb660982 100644 --- a/core-web/libs/edit-content/src/index.ts +++ b/core-web/libs/edit-content/src/index.ts @@ -1,6 +1,6 @@ export * from './lib/edit-content.routes'; export * from './lib/components/dot-create-content-dialog/dot-create-content-dialog.component'; -export * from './lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component'; +export * from './lib/fields/dot-edit-content-file-field/components/dot-binary-field-ce-bridge/dot-binary-field-ce-bridge.component'; export * from './lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component'; export * from './lib/fields/dot-edit-content-tag-field/components/tag-field/tag-field.component'; export * from './lib/models/dot-edit-content-dialog.interface'; diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html index 7a3835646962..0f50eae5a17b 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html @@ -103,15 +103,6 @@

{{ field.name }}

[attr.data-testId]="'field-' + field.variable" /> } } - @case (fieldTypes.BINARY) { - @defer (on immediate) { - - } - } @case (fieldTypes.CUSTOM_FIELD) { @defer (on immediate) { {{ field.name }} [field]="field" /> } } + @case (fieldTypes.BINARY) { + @defer (on immediate) { + + } + } @case (fieldTypes.FILE) { @defer (on immediate) { } } @@ -175,6 +176,7 @@

{{ field.name }}

} } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts index 45ca16672fcc..fad806237c51 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts @@ -37,7 +37,6 @@ import { monacoMock } from '@dotcms/utils-testing'; import { DotEditContentFieldComponent } from './dot-edit-content-field.component'; -import { DotBinaryFieldWrapperComponent } from '../../fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component'; import { DotEditContentBlockEditorComponent } from '../../fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component'; import { DotEditContentCalendarFieldComponent } from '../../fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component'; import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; @@ -184,8 +183,12 @@ const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTe ] }, [FIELD_TYPES.BINARY]: { - component: DotBinaryFieldWrapperComponent, + component: DotEditContentFileFieldComponent, providers: [ + { + provide: DotFileFieldUploadService, + useValue: {} + }, { provide: DotLicenseService, useValue: { @@ -201,8 +204,7 @@ const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTe { contentlet: BINARY_FIELD_CONTENTLET } - ], - outsideFormControl: true + ] }, [FIELD_TYPES.JSON]: { component: DotEditContentJsonFieldComponent, diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts index 6fa79857fa45..781db3f4e0d3 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts @@ -20,7 +20,6 @@ import { } from '@dotcms/dotcms-models'; import { GlobalStore } from '@dotcms/store'; -import { DotBinaryFieldWrapperComponent } from '../../fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component'; import { DotEditContentBlockEditorComponent } from '../../fields/dot-edit-content-block-editor/dot-edit-content-block-editor.component'; import { DotEditContentCalendarFieldComponent } from '../../fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component'; import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; @@ -61,7 +60,6 @@ import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; DotEditContentTagFieldComponent, DotEditContentCheckboxFieldComponent, DotEditContentMultiSelectFieldComponent, - DotBinaryFieldWrapperComponent, DotEditContentJsonFieldComponent, DotEditContentCustomFieldComponent, DotEditContentWYSIWYGFieldComponent, diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.html deleted file mode 100644 index 4af9f1bfbfa2..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.html +++ /dev/null @@ -1,55 +0,0 @@ -
- @if (allowFileNameEdit) { -
-
- - -
-
- -
-
- } -
- - -
- - Mime Type: {{ mimeType }} -
-
-
- - - -
-
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.scss deleted file mode 100644 index cf8011c88d99..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.scss +++ /dev/null @@ -1,82 +0,0 @@ -@use "variables" as *; - -.binary-field__editor-container { - display: flex; - justify-content: center; - align-items: flex-start; - flex-direction: column; - flex: 1; - width: 100%; - gap: $spacing-1; -} - -.binary-field__code-editor { - border: 1px solid $color-palette-gray-400; // Input - display: block; - flex-grow: 1; - width: 100%; - min-height: 20rem; - border-radius: $border-radius-md; - overflow: auto; -} - -.binary-field__code-editor--disabled { - background-color: $color-palette-gray-200; - opacity: 0.5; - - &::ng-deep { - .monaco-mouse-cursor-text, - .overflow-guard { - cursor: not-allowed; - } - } -} - -.editor-mode__form { - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; -} - -.editor-mode__input-container { - width: 100%; - display: flex; - gap: $spacing-1; - flex-direction: column; -} - -.editor-mode__input { - width: 100%; - display: flex; - flex-direction: column; -} - -.editor-mode__actions { - width: 100%; - display: flex; - gap: $spacing-1; - align-items: center; - justify-content: flex-end; -} - -.editor-mode__helper { - display: flex; - justify-content: flex-start; - align-items: center; - gap: $spacing-1; - color: $color-palette-gray-700; - font-weight: $font-size-sm; - visibility: hidden; -} - -.editor-mode__helper--visible { - visibility: visible; -} - -.error-message { - min-height: $spacing-4; // Fix height to avoid jumping - justify-content: flex-start; - display: flex; -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.spec.ts deleted file mode 100644 index 7568e607c65a..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.spec.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { MonacoEditorComponent, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; -import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { MockModule } from 'ng-mocks'; - -import { fakeAsync, tick } from '@angular/core/testing'; - -import { DotMessageService, DotUploadService } from '@dotcms/data-access'; - -import { DotBinaryFieldEditorComponent } from './dot-binary-field-editor.component'; - -import { DEFAULT_BINARY_FIELD_MONACO_CONFIG } from '../../dot-edit-content-binary-field.component'; -import { DotBinaryFieldValidatorService } from '../../service/dot-binary-field-validator/dot-binary-field-validator.service'; -import { TEMP_FILE_MOCK } from '../../store/binary-field.store.spec'; -import { CONTENTTYPE_FIELDS_MESSAGE_MOCK } from '../../utils/mock'; - -const EDITOR_MOCK = { - updateOptions: (_options) => { - /* noops */ - }, - addCommand: () => { - /* noops */ - }, - createContextKey: () => { - /* noops */ - }, - addAction: () => { - /* noops */ - }, - getOption: () => { - /* noops */ - } -} as unknown; - -globalThis.monaco = { - languages: { - getLanguages: () => { - return [ - { - id: 'javascript', - extensions: ['.js'], - mimetypes: ['text/javascript'] - } - ]; - } - } -} as typeof monaco; - -describe('DotBinaryFieldEditorComponent', () => { - let component: DotBinaryFieldEditorComponent; - let spectator: Spectator; - - let dotBinaryFieldValidatorService: DotBinaryFieldValidatorService; - - let dotUploadService: DotUploadService; - - const createComponent = createComponentFactory({ - component: DotBinaryFieldEditorComponent, - overrideComponents: [ - [ - DotBinaryFieldEditorComponent, - { - remove: { imports: [MonacoEditorModule] }, - add: { imports: [MockModule(MonacoEditorModule)] } - } - ] - ], - providers: [ - DotBinaryFieldValidatorService, - { - provide: DotUploadService, - useValue: { - uploadFile: ({ file }) => { - return new Promise((resolve) => { - if (file) { - resolve(TEMP_FILE_MOCK); - } - }); - } - } - }, - { - provide: DotMessageService, - useValue: CONTENTTYPE_FIELDS_MESSAGE_MOCK - } - ] - }); - - beforeEach(() => { - spectator = createComponent({ - detectChanges: false, - props: { - allowFileNameEdit: true - } - }); - - component = spectator.component; - component.editorRef.editor = EDITOR_MOCK as monaco.editor.IStandaloneCodeEditor; - dotUploadService = spectator.inject(DotUploadService, true); - dotBinaryFieldValidatorService = spectator.inject(DotBinaryFieldValidatorService); - dotBinaryFieldValidatorService.setAccept(['image/*', '.ts']); - - spectator.detectChanges(); - }); - - it('should emit cancel event on escape keydown', () => { - const event = new KeyboardEvent('keydown', { key: 'Escape' }); - - const cancelSpy = jest.spyOn(spectator.component.cancel, 'emit'); - const preventDefaultSpy = jest.spyOn(event, 'preventDefault'); - const stopPropagationSpy = jest.spyOn(event, 'stopPropagation'); - - document.dispatchEvent(event); - - expect(cancelSpy).toHaveBeenCalled(); - expect(preventDefaultSpy).toHaveBeenCalled(); - expect(stopPropagationSpy).toHaveBeenCalled(); - }); - - describe('input', () => { - it('should set label and have css class required', () => { - const label = spectator.query(byTestId('editor-label')); - - expect(label.innerHTML.trim()).toBe('File Name'); - expect(label.className).toBe('p-label-input-required'); - }); - - it('should show the file name editor', () => { - const input = spectator.query(byTestId('editor-file-name')); - - expect(input).not.toBeNull(); - }); - - it('should not show the file name editor', () => { - spectator.setInput('allowFileNameEdit', false); - spectator.detectChanges(); - - const input = spectator.query(byTestId('editor-file-name')); - - expect(input).toBeNull(); - }); - }); - - describe('Editor', () => { - it('should set editor language', fakeAsync(() => { - const expectedMonacoOptions = { - ...DEFAULT_BINARY_FIELD_MONACO_CONFIG, - language: 'javascript' - }; - - spectator.detectChanges(); - - component.form.setValue({ - name: 'script.js', - content: 'test' - }); - - spectator.detectComponentChanges(); - - tick(355); //due to debounceTime - - expect(component.monacoOptions()).toEqual(expectedMonacoOptions); - expect(component.mimeType).toBe('text/javascript'); - })); - it('should force html language on vtl files', fakeAsync(() => { - const expectedMonacoOptions = { - ...DEFAULT_BINARY_FIELD_MONACO_CONFIG, - language: 'velocity' - }; - - spectator.detectChanges(); - - component.form.setValue({ - name: 'banner.vtl', - content: 'test' - }); - - spectator.detectComponentChanges(); - - tick(355); - - expect(component.monacoOptions()).toEqual(expectedMonacoOptions); - expect(component.mimeType).toBe('text/x-velocity'); - })); - it('should fallback with plain text if language is not found', fakeAsync(() => { - const expectedMonacoOptions = { - ...DEFAULT_BINARY_FIELD_MONACO_CONFIG, - language: 'text' - }; - - spectator.detectChanges(); - - component.form.setValue({ - name: 'script.rb', - content: 'test' - }); - - spectator.detectComponentChanges(); - - tick(355); - - expect(component.monacoOptions()).toEqual(expectedMonacoOptions); - expect(component.mimeType).toBe('plain/text'); - })); - it('should emit cancel event when cancel button is clicked', () => { - const spy = jest.spyOn(component.cancel, 'emit'); - const cancelBtn = spectator.query(byTestId('cancel-button')); - - spectator.click(cancelBtn); - - expect(spy).toHaveBeenCalled(); - }); - - it('should emit tempFileUploaded event when import button is clicked if form is valid', () => { - const spy = jest.spyOn(component.tempFileUploaded, 'emit'); - const spyFormDisabled = jest.spyOn(component.form, 'disable'); - const spyFormEnabled = jest.spyOn(component.form, 'enable'); - const spyFileUpload = jest - .spyOn(dotUploadService, 'uploadFile') - .mockReturnValue(Promise.resolve(TEMP_FILE_MOCK)); - const importBtn = spectator.query('[data-testId="import-button"] button'); - const monacoEditor = spectator.query(MonacoEditorComponent); - monacoEditor.init.emit(); - - component.form.setValue({ - name: 'file-name.ts', - content: 'test' - }); - - spectator.click(importBtn); - - expect(spy).toHaveBeenCalledWith(TEMP_FILE_MOCK); - expect(spyFileUpload).toHaveBeenCalled(); - expect(spyFormDisabled).toHaveBeenCalled(); - expect(spyFormEnabled).toHaveBeenCalled(); - }); - - it('should not emit tempFileUploaded event when import button is clicked if form is invalid', () => { - const spy = jest.spyOn(component.tempFileUploaded, 'emit'); - const spyFormDisabled = jest.spyOn(component.form, 'disable'); - const spyFormEnabled = jest.spyOn(component.form, 'enable'); - const spyFileUpload = jest - .spyOn(dotUploadService, 'uploadFile') - .mockReturnValue(Promise.resolve(TEMP_FILE_MOCK)); - const importBtn = spectator.query('[data-testId="import-button"] button'); - - component.form.setValue({ - name: '', - content: '' - }); - - spectator.click(importBtn); - - expect(spyFileUpload).not.toHaveBeenCalled(); - expect(spyFormDisabled).not.toHaveBeenCalled(); - expect(spyFormEnabled).not.toHaveBeenCalled(); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should mark name control as dirty when import button is clicked and name control is invalid', () => { - const spyDirty = jest.spyOn(component.form.get('name'), 'markAsDirty'); - const spyDdateValueAndValidity = jest.spyOn( - component.form.get('name'), - 'updateValueAndValidity' - ); - const importBtn = spectator.query('[data-testId="import-button"] button'); - - spectator.click(importBtn); - - expect(spyDirty).toHaveBeenCalled(); - expect(spyDdateValueAndValidity).toHaveBeenCalled(); - }); - - it('should set form as invalid when accept is not valid', fakeAsync(() => { - const spy = jest.spyOn(component.name, 'setErrors'); - - component.form.setValue({ - name: 'test.ts', - content: 'test' - }); - - tick(1000); - - expect(spy).toHaveBeenCalledWith({ - invalidExtension: - 'This type of file is not supported. Please use a image/*, .ts file.' - }); - expect(component.form.valid).toBe(false); - })); - - it('should set form as valid when there is no extension', fakeAsync(() => { - dotBinaryFieldValidatorService.setAccept([]); - spectator.detectChanges(); - - const spy = jest.spyOn(component.name, 'setErrors'); - - component.form.setValue({ - name: 'testNoExtension', - content: 'test' - }); - - tick(1000); - - expect(spy).not.toHaveBeenCalled(); - expect(component.form.valid).toBe(true); - })); - - afterEach(() => { - jest.restoreAllMocks(); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.ts deleted file mode 100644 index 361c193df52b..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-editor/dot-binary-field-editor.component.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { - MonacoEditorComponent, - MonacoEditorConstructionOptions, - MonacoEditorModule -} from '@materia-ui/ngx-monaco-editor'; -import { from } from 'rxjs'; - -import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - computed, - EventEmitter, - HostListener, - inject, - Input, - OnChanges, - OnInit, - Output, - signal, - ViewChild -} from '@angular/core'; -import { - FormControl, - FormGroup, - FormsModule, - ReactiveFormsModule, - Validators -} from '@angular/forms'; - -import { ButtonModule } from 'primeng/button'; -import { InputTextModule } from 'primeng/inputtext'; - -import { debounceTime } from 'rxjs/operators'; - -import { DotMessageService, DotUploadService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DotFieldValidationMessageComponent, DotMessagePipe } from '@dotcms/ui'; - -import { dotVelocityLanguageDefinition } from '../../../../custom-languages/velocity-monaco-language'; -import { DEFAULT_BINARY_FIELD_MONACO_CONFIG } from '../../dot-edit-content-binary-field.component'; -import { DotBinaryFieldValidatorService } from '../../service/dot-binary-field-validator/dot-binary-field-validator.service'; - -const DEFAULT_FILE_TYPE = 'text'; -@Component({ - selector: 'dot-binary-field-editor', - imports: [ - MonacoEditorModule, - FormsModule, - ReactiveFormsModule, - InputTextModule, - ButtonModule, - DotMessagePipe, - DotFieldValidationMessageComponent - ], - templateUrl: './dot-binary-field-editor.component.html', - styleUrls: ['./dot-binary-field-editor.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotBinaryFieldEditorComponent implements OnInit, OnChanges { - @Input() fileName = ''; - @Input() fileContent = ''; - @Input() allowFileNameEdit = true; - - @Output() readonly tempFileUploaded = new EventEmitter(); - @Output() readonly cancel = new EventEmitter(); - @ViewChild('editorRef', { static: true }) editorRef!: MonacoEditorComponent; - readonly form = new FormGroup({ - name: new FormControl('', [Validators.required]), - content: new FormControl('') - }); - mimeType = ''; - private readonly languageType = signal(DEFAULT_FILE_TYPE); - private readonly cd: ChangeDetectorRef = inject(ChangeDetectorRef); - private readonly dotUploadService: DotUploadService = inject(DotUploadService); - private readonly dotMessageService: DotMessageService = inject(DotMessageService); - private readonly dotBinaryFieldValidatorService: DotBinaryFieldValidatorService = inject( - DotBinaryFieldValidatorService - ); - private extension = ''; - private invalidFileMessage = ''; - private editor: monaco.editor.IStandaloneCodeEditor; - - private _userMonacoOptions = signal({}); - - monacoOptions = computed(() => { - return { - ...DEFAULT_BINARY_FIELD_MONACO_CONFIG, - ...this._userMonacoOptions(), - language: this.languageType() - }; - }); - - @Input() - set userMonacoOptions(customMonacoOptions: MonacoEditorConstructionOptions) { - this._userMonacoOptions.set(customMonacoOptions); - } - - get name(): FormControl { - return this.form.get('name') as FormControl; - } - - get content(): FormControl { - return this.form.get('content') as FormControl; - } - - /** - * Close the editor when the user press ESC - * And prevent the default behavior of the edit conten iframe - * - * @param {*} event - * @memberof DotBinaryFieldEditorComponent - */ - @HostListener('document:keydown.escape', ['$event']) onEscape(event) { - // TODO: The 'emit' function requires a mandatory void argument - this.cancel.emit(); - event.preventDefault(); - event.stopPropagation(); - } - - ngOnInit(): void { - this.setFormValues(); - this.name.valueChanges - .pipe(debounceTime(350)) - .subscribe((name) => this.setEditorLanguage(name)); - this.invalidFileMessage = this.dotMessageService.get( - 'dot.binary.field.error.type.file.not.supported.message', - this.dotBinaryFieldValidatorService.accept.join(', ') - ); - } - - ngOnChanges(): void { - this.setFormValues(); - if (window.monaco) { - this.setEditorLanguage(this.fileName); - } - } - - onEditorInit() { - this.editor = this.editorRef.editor; - if (this.fileName) { - this.setEditorLanguage(this.fileName); - } - - window.monaco.languages.register({ - id: 'velocity', - extensions: ['.vtl'], - mimetypes: ['text/x-velocity'] - }); - - window.monaco.languages.setMonarchTokensProvider('velocity', dotVelocityLanguageDefinition); - } - - onSubmit(): void { - if (this.name.invalid) { - this.markControlInvalid(this.name); - - return; - } - - const file = new File([this.content.value], this.name.value, { - type: this.mimeType - }); - this.uploadFile(file); - } - - private setFormValues(): void { - this.name.setValue(this.fileName); - this.content.setValue(this.fileContent); - } - - private markControlInvalid(control: FormControl): void { - control.markAsDirty(); - control.updateValueAndValidity(); - this.cd.detectChanges(); - } - - private uploadFile(file: File) { - const obs$ = from(this.dotUploadService.uploadFile({ file })); - this.disableEditor(); - obs$.subscribe((tempFile) => { - this.enableEditor(); - this.tempFileUploaded.emit({ - ...tempFile, - content: this.content.value - }); - }); - } - - private setEditorLanguage(fileName = '') { - const fileExtension = this.extractFileExtension(fileName); - - if (fileExtension === 'vtl') { - this.setVelocityLanguage(); - } else { - this.updateLanguageForFileExtension(fileExtension); - } - - this.validateFileType(); - this.cd.detectChanges(); - } - - private extractFileExtension(fileName: string) { - return fileName?.includes('.') ? fileName.split('.').pop() : ''; - } - - private setVelocityLanguage() { - this.mimeType = 'text/x-velocity'; - this.extension = 'vtl'; - this.updateEditorLanguage('velocity'); - } - - private updateLanguageForFileExtension(fileExtension: string) { - const { id, mimetypes, extensions } = this.getLanguage(fileExtension) || {}; - this.mimeType = mimetypes?.[0] || 'plain/text'; - this.extension = extensions?.[0] || 'txt'; - this.updateEditorLanguage(id); - } - - private validateFileType() { - const isValidType = this.dotBinaryFieldValidatorService.isValidType({ - extension: this.extension, - mimeType: this.mimeType - }); - if (!isValidType) { - this.name.setErrors({ invalidExtension: this.invalidFileMessage }); - } - } - - private getLanguage(fileExtension: string) { - // Global Object Defined by Monaco Editor - return window.monaco.languages - .getLanguages() - .find((language) => language.extensions?.includes(`.${fileExtension}`)); - } - - private updateEditorLanguage(languageId = 'text') { - this.languageType.set(languageId); - } - - private disableEditor() { - this.form.disable(); - this.editor.updateOptions({ - readOnly: true - }); - } - - private enableEditor() { - this.form.enable(); - this.editor.updateOptions({ - readOnly: false - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.html deleted file mode 100644 index ef773736afc5..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.html +++ /dev/null @@ -1,167 +0,0 @@ -
- @if (this.metadata?.editableAsText) { -
- {{ content() }} -
- } @else { -
- @if (tempFile) { - - } @else { - - } -
- - } - - - - - - -
- - -
- {{ 'Size' | dm }}: - -
- - @if (contentlet) { - @for (sourceLink of this.resourceLinks(); track $index) { - @if (sourceLink.show) { -
- } - } @empty { - @for (item of [1, 2, 3, 4]; track $index) { -
- - -
- } - } - } - - - - - diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss deleted file mode 100644 index b8b6453bae7e..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.scss +++ /dev/null @@ -1,208 +0,0 @@ -@use "variables" as *; - -:host { - display: block; - width: 100%; - height: 100%; -} - -dot-contentlet-thumbnail::ng-deep { - .background-image:not(.svg-thumbnail) { - img { - object-fit: cover; - } - } - - img { - object-fit: contain; - } -} - -.preview-container { - display: flex; - gap: $spacing-1; - align-items: flex-start; - justify-content: center; - height: 100%; - width: 100%; - position: relative; - container-type: inline-size; - container-name: preview; - - &:only-child { - gap: 0; // Remove gap if only one preview - } -} - -.preview-code_container { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 100%; - user-select: none; - cursor: pointer; -} - -.preview-image__container, -dot-contentlet-thumbnail { - height: 100%; - width: 100%; - display: flex; - justify-content: center; - align-items: center; - background: $color-palette-gray-200; -} - -.preview-container--fade::after { - content: ""; - background: linear-gradient(0deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%); - position: absolute; - width: 100%; - height: 50%; - bottom: 0; - left: 0; - border-radius: $border-radius-md; - pointer-events: none; -} - -.preview-metadata__container { - flex-grow: 1; - padding: $spacing-1; - padding-right: $spacing-6; - display: none; - flex-direction: column; - overflow: hidden; - gap: $spacing-2; - min-width: 150px; - - span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .preview-metadata_header { - font-size: $font-size-md; - font-weight: $font-weight-semi-bold; - margin: 0; - color: $black; - } -} - -.preview-metadata { - display: flex; - justify-content: flex-start; - align-items: center; - gap: $spacing-0; -} - -.preview-resource-links__actions { - position: absolute; - top: 0; - right: 0; - display: none; - flex-direction: column; - gap: $spacing-0; - padding-top: $spacing-1; -} - -.preview-metadata__actions { - position: absolute; - bottom: $spacing-1; - right: 0; - display: none; - justify-content: flex-end; - align-items: center; - gap: $spacing-1; - z-index: 100; -} - -.preview-metadata__action--responsive { - position: absolute; - bottom: $spacing-1; - right: $spacing-1; - display: flex; - flex-direction: column; - gap: $spacing-1; - z-index: 100; -} - -code { - background: $white; - color: $color-palette-primary-500; - height: 100%; - width: 100%; - white-space: pre-wrap; - overflow: hidden; - line-height: normal; -} - -.file-info__item { - display: flex; - padding: $spacing-0 0; - flex-direction: column; - justify-content: center; - align-items: flex-start; - gap: $spacing-0; - - &:not(:last-child)::after { - content: ""; - display: block; - width: 100%; - height: 1px; - background: $color-palette-gray-200; - margin: $spacing-1 0; - } -} - -.file-info__link { - display: flex; - align-items: center; - gap: $spacing-1; - min-height: 32px; - font-size: $font-size-sm; - width: 100%; - - a { - color: $black; - text-decoration: none; - flex: 1 0 0; - } -} - -.file-info__title { - font-size: $font-size-sm; - font-style: normal; - font-weight: 600; -} - -.file-info__size { - display: flex; - align-items: center; - gap: $spacing-0; -} - -@container preview (min-width: 500px) { - .preview-metadata__container, - .preview-metadata__actions { - display: flex; - } - - .preview-metadata__action--responsive { - display: none; - } - - .preview-image__container { - height: 100%; - max-width: 17.5rem; - } - - .preview-resource-links__actions { - display: flex; - } - - .preview-overlay__container { - display: none; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.spec.ts deleted file mode 100644 index 165ac081b3e3..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.spec.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; - -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { fakeAsync, tick } from '@angular/core/testing'; - -import { delay } from 'rxjs/operators'; - -import { DotResourceLinksService } from '@dotcms/data-access'; - -import { DotBinaryFieldPreviewComponent } from './dot-binary-field-preview.component'; - -import { BINARY_FIELD_CONTENTLET } from '../../../../utils/mocks'; -import { TEMP_FILES_MOCK } from '../../utils/mock'; - -const CONTENTLET_MOCK = { - ...BINARY_FIELD_CONTENTLET, - baseType: 'FILEASSET', - fieldVariable: 'Binary' -}; - -const CONTENTLET_HTMLPAGE_MOCK = { - ...BINARY_FIELD_CONTENTLET, - baseType: 'HTMLPAGE', - fieldVariable: 'Binary' -}; - -const CONTENTLET_TEXT_MOCK = { - ...BINARY_FIELD_CONTENTLET, - BinaryMetaData: { - ...BINARY_FIELD_CONTENTLET.binaryMetaData, - editableAsText: true, - contentType: 'text/plain' - }, - fieldVariable: 'Binary', - content: 'Data' -}; - -const clickOnInfoButton = (spectator: Spectator) => { - const infoButton = spectator.query(byTestId('info-btn')); - spectator.click(infoButton); - spectator.detectChanges(); -}; - -const queryGlobalByTestId = (testId: string): HTMLElement | null => - document.querySelector(`[data-testid="${testId}"]`); - -describe('DotBinaryFieldPreviewComponent', () => { - let spectator: Spectator; - let dotResourceLinksService: DotResourceLinksService; - - const createComponent = createComponentFactory({ - component: DotBinaryFieldPreviewComponent, - imports: [HttpClientTestingModule], - providers: [ - { - provide: DotResourceLinksService, - useValue: { - getFileResourceLinks: () => of({}) - } - } - ] - }); - - beforeEach(() => { - spectator = createComponent({ - props: { - contentlet: CONTENTLET_MOCK, - fieldVariable: 'Binary', - tempFile: null, - editableImage: true - }, - detectChanges: false - }); - - dotResourceLinksService = spectator.inject(DotResourceLinksService, true); - }); - - it('should show contentlet thumbnail', () => { - spectator.detectChanges(); - const thumbnail = spectator.query(byTestId('contentlet-thumbnail')) as Element; - expect(thumbnail).toBeTruthy(); - expect(thumbnail['fieldVariable']).toBe(CONTENTLET_MOCK.fieldVariable); - expect(thumbnail['contentlet']).toEqual(CONTENTLET_MOCK); - }); - - it('should show temp file thumbnail', () => { - spectator.setInput('tempFile', TEMP_FILES_MOCK[0]); - spectator.setInput('contentlet', null); - - spectator.detectChanges(); - expect(spectator.query(byTestId('temp-file-thumbnail'))).toBeTruthy(); - }); - - it('should emit removeFile event when remove button is clicked', () => { - const spy = jest.spyOn(spectator.component.removeFile, 'emit'); - const removeButton = spectator.query(byTestId('remove-button')); - spectator.click(removeButton); - expect(spy).toHaveBeenCalled(); - }); - - it('should show download button', () => { - spectator.detectChanges(); - const downloadButton = spectator.query(byTestId('download-btn')); - const spyWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null); - - expect(downloadButton).toBeTruthy(); - - spectator.click(downloadButton); - spectator.detectChanges(); - - expect(spyWindowOpen).toHaveBeenCalledWith( - `/contentAsset/raw-data/${CONTENTLET_MOCK.inode}/${CONTENTLET_MOCK.fieldVariable}?byInode=true&force_download=true`, - '_self' - ); - }); - - it("should doesn't show download button", () => { - spectator.setInput('tempFile', TEMP_FILES_MOCK[0]); - spectator.setInput('contentlet', null); - spectator.detectChanges(); - const downloadButton = spectator.query(byTestId('download-btn')); - - expect(downloadButton).toBeNull(); - }); - - it('should be editable', () => { - spectator.detectChanges(); - const editButton = spectator.query(byTestId('edit-button')); - expect(editButton).toBeTruthy(); - }); - - it('should show download button responsive', () => { - spectator.detectChanges(); - const downloadButtonResponsive = spectator.query(byTestId('download-btn-responsive')); - const spyWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null); - - expect(downloadButtonResponsive).toBeTruthy(); - - spectator.click(downloadButtonResponsive); - spectator.detectChanges(); - - expect(spyWindowOpen).toHaveBeenCalledWith( - `/contentAsset/raw-data/${CONTENTLET_MOCK.inode}/${CONTENTLET_MOCK.fieldVariable}?byInode=true&force_download=true`, - '_self' - ); - }); - - describe('onEdit', () => { - describe('when file is an image', () => { - it('should emit editImage event', () => { - spectator.detectChanges(); - const spy = jest.spyOn(spectator.component.editImage, 'emit'); - const editButton = spectator.query(byTestId('edit-button')); - spectator.click(editButton); - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('when contentelt is a text file', () => { - beforeEach(() => { - spectator.setInput('contentlet', CONTENTLET_TEXT_MOCK); - spectator.detectChanges(); - }); - - it('should emit editFile event when edit button is clicked', () => { - const spy = jest.spyOn(spectator.component.editFile, 'emit'); - const editButton = spectator.query(byTestId('edit-button')); - spectator.click(editButton); - expect(spy).toHaveBeenCalled(); - }); - - it('should emit editFile event click on the code preview', () => { - const spy = jest.spyOn(spectator.component.editFile, 'emit'); - const codePreview = spectator.query(byTestId('code-preview')); - spectator.click(codePreview); - expect(spy).toHaveBeenCalled(); - }); - }); - }); - - describe('editableImage', () => { - describe('when is true', () => { - it('should set isEditable to true', () => { - spectator.detectChanges(); - const editButton = spectator.query(byTestId('edit-button')); - expect(editButton).toBeTruthy(); - }); - }); - - describe('when is false', () => { - beforeEach(async () => { - spectator.setInput('editableImage', false); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - }); - - it('should set isEditable to false', () => { - const editButton = spectator.query(byTestId('edit-button')); - expect(editButton).not.toBeTruthy(); - }); - }); - }); - - describe('responsive', () => { - it('should emit removeFile event when remove button is clicked', () => { - const spy = jest.spyOn(spectator.component.removeFile, 'emit'); - const removeButton = spectator.query(byTestId('remove-button-responsive')); - spectator.click(removeButton); - expect(spy).toHaveBeenCalled(); - }); - - describe('onEdit', () => { - describe('when file is an image', () => { - it('should emit editImage event', () => { - spectator.detectChanges(); - const spy = jest.spyOn(spectator.component.editImage, 'emit'); - const editButton = spectator.query(byTestId('edit-button-responsive')); - spectator.click(editButton); - expect(spy).toHaveBeenCalled(); - }); - }); - - describe('when the contentlet is a text file', () => { - beforeEach(() => { - spectator.setInput('contentlet', CONTENTLET_TEXT_MOCK); - spectator.detectChanges(); - }); - - it('should emit editFile event when edit button is clicked', () => { - const spy = jest.spyOn(spectator.component.editFile, 'emit'); - const editButton = spectator.query(byTestId('edit-button-responsive')); - spectator.click(editButton); - expect(spy).toHaveBeenCalled(); - }); - }); - }); - }); - - describe('Resource Links', () => { - const RESOURCE_LINKS = { - configuredImageURL: '/configuredImageURL', - text: '/text', - versionPath: '/versionPath', - idPath: '/idPath', - mimeType: 'image/png' - }; - - it('should have the correct resource links', fakeAsync(() => { - const spyResourceLinks = jest - .spyOn(dotResourceLinksService, 'getFileResourceLinksByInode') - .mockReturnValue(of(RESOURCE_LINKS)); - - spectator.detectChanges(); - - clickOnInfoButton(spectator); - tick(); - spectator.detectChanges(); - - const fileLinkElement = queryGlobalByTestId('resource-link-FileLink'); - const resourceLinkElement = queryGlobalByTestId('resource-link-Resource-Link'); - const versionPathElement = queryGlobalByTestId('resource-link-VersionPath'); - const idPathElement = queryGlobalByTestId('resource-link-IdPath'); - - expect(fileLinkElement).not.toBeNull(); - expect(resourceLinkElement).not.toBeNull(); - expect(versionPathElement).not.toBeNull(); - expect(idPathElement).not.toBeNull(); - - expect(spyResourceLinks).toHaveBeenCalledWith({ - fieldVariable: 'Binary', - inode: CONTENTLET_MOCK.inode - }); - })); - - it('should not have the Resource-Link', () => { - const spyResourceLinks = jest - .spyOn(dotResourceLinksService, 'getFileResourceLinksByInode') - .mockReturnValue(of(RESOURCE_LINKS)); - spectator.setInput('contentlet', CONTENTLET_HTMLPAGE_MOCK); - - spectator.detectChanges(); - - clickOnInfoButton(spectator); - - const resourceLinkElement = spectator.query(byTestId('resource-link-Resource-Link')); - - expect(resourceLinkElement).toBeNull(); - expect(spyResourceLinks).toHaveBeenCalledWith({ - fieldVariable: 'Binary', - inode: CONTENTLET_MOCK.inode - }); - }); - - it('should have the loading state', fakeAsync(() => { - const spyResourceLinks = jest - .spyOn(dotResourceLinksService, 'getFileResourceLinksByInode') - .mockReturnValue(of(RESOURCE_LINKS).pipe(delay(1000))); - - spectator.detectChanges(); - - clickOnInfoButton(spectator); - - const loadingElements = spectator.queryAll('.file-info__loading'); - - expect(loadingElements.length).toBe(4); - - tick(1000); - - expect(spyResourceLinks).toHaveBeenCalledWith({ - fieldVariable: 'Binary', - inode: CONTENTLET_MOCK.inode - }); - })); - - it('should not show file resolution', () => { - spectator.setInput('contentlet', { - ...CONTENTLET_MOCK, - BinaryMetaData: { - ...BINARY_FIELD_CONTENTLET.binaryMetaData, - height: 0, - width: 0 - } - }); - - spectator.detectChanges(); - - clickOnInfoButton(spectator); - - const resolution = spectator.query(byTestId('file-resolution')); - expect(resolution).toBeNull(); - }); - }); - - describe('Disabled State Management', () => { - beforeEach(() => { - spectator.setInput('disabled', true); - spectator.detectChanges(); - }); - - it('should disable info button when disabled', () => { - const infoBtnComponent = spectator.query(byTestId('info-btn')); - const actualButton = infoBtnComponent.querySelector('button') as HTMLButtonElement; - expect(infoBtnComponent).toBeTruthy(); - expect(actualButton.disabled).toBe(true); - }); - - it('should disable download button when disabled', () => { - const downloadBtnComponent = spectator.query(byTestId('download-btn')); - const actualButton = downloadBtnComponent.querySelector('button') as HTMLButtonElement; - expect(downloadBtnComponent).toBeTruthy(); - expect(actualButton.disabled).toBe(true); - }); - - it('should disable remove button when disabled', () => { - const removeBtnComponent = spectator.query(byTestId('remove-button')); - const actualButton = removeBtnComponent.querySelector('button') as HTMLButtonElement; - expect(removeBtnComponent).toBeTruthy(); - expect(actualButton.disabled).toBe(true); - }); - - it('should disable edit button when disabled', () => { - const editBtnComponent = spectator.query(byTestId('edit-button')); - const actualButton = editBtnComponent.querySelector('button') as HTMLButtonElement; - expect(editBtnComponent).toBeTruthy(); - expect(actualButton.disabled).toBe(true); - }); - - it('should disable responsive buttons when disabled', () => { - const infoResponsiveBtnComponent = spectator.query(byTestId('infor-button-responsive')); - const downloadResponsiveBtnComponent = spectator.query( - byTestId('download-btn-responsive') - ); - const removeResponsiveBtnComponent = spectator.query( - byTestId('remove-button-responsive') - ); - const editResponsiveBtnComponent = spectator.query(byTestId('edit-button-responsive')); - - expect(infoResponsiveBtnComponent.querySelector('button').disabled).toBe(true); - expect(downloadResponsiveBtnComponent.querySelector('button').disabled).toBe(true); - expect(removeResponsiveBtnComponent.querySelector('button').disabled).toBe(true); - expect(editResponsiveBtnComponent?.querySelector('button').disabled).toBe(true); - }); - - it('should prevent download action when disabled', () => { - // Clean up any existing spies and create a fresh one - jest.restoreAllMocks(); - const spyWindowOpen = jest.spyOn(window, 'open').mockImplementation(() => null); - - spectator.component.downloadAsset(); - - expect(spyWindowOpen).not.toHaveBeenCalled(); - spyWindowOpen.mockRestore(); - }); - - it('should prevent edit action when disabled', () => { - const editImageSpy = jest.spyOn(spectator.component.editImage, 'emit'); - const editFileSpy = jest.spyOn(spectator.component.editFile, 'emit'); - - spectator.component.onEdit(); - - expect(editImageSpy).not.toHaveBeenCalled(); - expect(editFileSpy).not.toHaveBeenCalled(); - }); - - it('should not trigger actions when clicking disabled buttons', () => { - const editImageSpy = jest.spyOn(spectator.component.editImage, 'emit'); - const removeFileSpy = jest.spyOn(spectator.component.removeFile, 'emit'); - - // Get the actual button elements inside the PrimeNG components - const editBtnComponent = spectator.query(byTestId('edit-button')); - const removeBtnComponent = spectator.query(byTestId('remove-button')); - - const actualEditBtn = editBtnComponent.querySelector('button'); - const actualRemoveBtn = removeBtnComponent.querySelector('button'); - - // Verify buttons are actually disabled - expect(actualEditBtn.disabled).toBe(true); - expect(actualRemoveBtn.disabled).toBe(true); - - // Try to click the disabled buttons - clicks on disabled buttons should not trigger handlers - spectator.click(actualEditBtn); - spectator.click(actualRemoveBtn); - - // Since buttons are disabled, events should not be emitted - expect(editImageSpy).not.toHaveBeenCalled(); - expect(removeFileSpy).not.toHaveBeenCalled(); - }); - - it('should show remove button as disabled when component is disabled', () => { - const removeBtnComponent = spectator.query(byTestId('remove-button')); - const actualButton = removeBtnComponent.querySelector('button') as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - it('should show edit button as disabled when component is disabled', () => { - const editBtnComponent = spectator.query(byTestId('edit-button')); - const actualButton = editBtnComponent.querySelector('button') as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - it('should show download button as disabled when component is disabled', () => { - const downloadBtnComponent = spectator.query(byTestId('download-btn')); - const actualButton = downloadBtnComponent.querySelector('button') as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - describe('with text file', () => { - let textFileSpectator: Spectator; - - beforeEach(() => { - // Create a fresh component instance with text file contentlet - textFileSpectator = createComponent({ - props: { - contentlet: CONTENTLET_TEXT_MOCK, - fieldVariable: 'Binary', - tempFile: null, - editableImage: true, - disabled: true - }, - detectChanges: true - }); - }); - - it('should show edit button as disabled for text files', () => { - const editBtnComponent = textFileSpectator.query(byTestId('edit-button')); - const actualButton = editBtnComponent.querySelector('button') as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - it('should show code preview for text files when disabled', () => { - // Code preview should still be visible but interactions would be handled by disabled state - const codePreview = textFileSpectator.query(byTestId('code-preview')); - expect(codePreview).toBeTruthy(); - expect(codePreview.textContent.trim()).toContain('Data'); - }); - }); - - describe('responsive actions when disabled', () => { - // Note: disabled state is already set in parent beforeEach - - it('should show responsive remove button as disabled', () => { - const removeBtnComponent = spectator.query(byTestId('remove-button-responsive')); - const actualButton = removeBtnComponent.querySelector( - 'button' - ) as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - it('should show responsive edit button as disabled', () => { - const editBtnComponent = spectator.query(byTestId('edit-button-responsive')); - const actualButton = editBtnComponent?.querySelector('button') as HTMLButtonElement; - expect(actualButton?.disabled).toBe(true); - }); - - it('should show responsive download button as disabled', () => { - const downloadBtnComponent = spectator.query(byTestId('download-btn-responsive')); - const actualButton = downloadBtnComponent.querySelector( - 'button' - ) as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - - it('should show responsive info button as disabled', () => { - const infoBtnComponent = spectator.query(byTestId('infor-button-responsive')); - const actualButton = infoBtnComponent.querySelector('button') as HTMLButtonElement; - expect(actualButton.disabled).toBe(true); - }); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.stories.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.stories.ts deleted file mode 100644 index 2cb96498e672..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.stories.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import { moduleMetadata, StoryObj, Meta } from '@storybook/angular'; - -import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; -import { SkeletonModule } from 'primeng/skeleton'; - -import { DotResourceLinksService } from '@dotcms/data-access'; -import { - DotTempFileThumbnailComponent, - DotSpinnerModule, - DotCopyButtonComponent, - DotFileSizeFormatPipe, - DotMessagePipe -} from '@dotcms/ui'; - -import { DotBinaryFieldPreviewComponent } from './dot-binary-field-preview.component'; - -import { DotFilePreview } from '../../interfaces'; -import { fileMetaData } from '../../utils/mock'; - -const previewImage: DotFilePreview = { - ...fileMetaData, - id: '123', - inode: '123', - titleImage: 'Assets', - contentType: 'image/png', - name: 'test.png' -}; - -const previewVideo = { - type: 'image', - fileSize: 8000, - content: '', - mimeType: 'video/png', - inode: '123456789', - titlevideo: 'true', - name: 'video.jpg', - title: 'video.jpg', - - contentType: 'video/png' -}; - -const previewFile = { - type: 'file', - fileSize: 8000, - mimeType: 'text/html', - inode: '123456789', - titlevideo: 'true', - name: 'template.html', - title: 'template.html', - - contentType: 'text/html', - content: ` - - - - - Document - - -

I have styles

- - ` -}; - -type Args = DotBinaryFieldPreviewComponent & { - file: DotFilePreview; - variableName: string; - fieldVariable: string; - styles: string[]; -}; - -const meta: Meta = { - title: 'Library / Edit Content / Binary Field / Components / Preview', - component: DotBinaryFieldPreviewComponent, - decorators: [ - moduleMetadata({ - imports: [ - BrowserAnimationsModule, - CommonModule, - ButtonModule, - SkeletonModule, - DotTempFileThumbnailComponent, - DotSpinnerModule, - DialogModule, - DotMessagePipe, - DotFileSizeFormatPipe, - DotCopyButtonComponent, - HttpClientModule - ], - providers: [DotResourceLinksService] - }) - ], - parameters: { - actions: { - handles: ['editFile', 'removeFile'] - } - }, - args: { - file: previewImage, - variableName: 'binaryField', - styles: [ - ` - .container { - width: 100%; - max-width: 36rem; - height: 12.5rem; - border: 1px solid #f2f2f2; - border-radius: 4px; - padding: 0.5rem; - } - ` - ] - }, - argTypes: { - file: { - defaultValue: previewImage, - control: 'object', - description: 'Preview object' - }, - variableName: { - defaultValue: 'binaryField', - control: 'text', - description: 'Field variable name' - }, - fieldVariable: { - defaultValue: 'Blog', - control: 'text', - description: 'Field variable name' - } - }, - render: (args) => ({ - props: { - ...args, - editFile: action('editFile'), - removeFile: action('removeFile') - }, - template: ` -
- -
` - }) -}; -export default meta; - -type Story = StoryObj; - -export const Image: Story = {}; - -export const Video = { - args: { - file: previewVideo, - variableName: 'binaryField', - fieldVariable: 'Blog' - } -}; - -export const File = { - args: { - file: previewFile, - variableName: 'binaryField', - fieldVariable: 'Blog' - } -}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.ts deleted file mode 100644 index e3fc134bcad3..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-preview/dot-binary-field-preview.component.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { of } from 'rxjs'; - -import { - CUSTOM_ELEMENTS_SCHEMA, - ChangeDetectionStrategy, - Component, - EventEmitter, - Input, - OnChanges, - OnInit, - Output, - SimpleChanges, - inject, - signal -} from '@angular/core'; - -import { ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; -import { SkeletonModule } from 'primeng/skeleton'; - -import { catchError } from 'rxjs/operators'; - -import { DotResourceLinksService } from '@dotcms/data-access'; -import { - DotCMSBaseTypesContentTypes, - DotCMSContentlet, - DotCMSTempFile, - DotFileMetadata -} from '@dotcms/dotcms-models'; -import { - DotTempFileThumbnailComponent, - DotFileSizeFormatPipe, - DotMessagePipe, - DotCopyButtonComponent -} from '@dotcms/ui'; - -import { getFileMetadata } from '../../utils/binary-field-utils'; - -export enum EDITABLE_FILE { - image = 'image', - text = 'text', - unknown = 'unknown' -} - -interface dotPreviewResourceLink { - key: string; - value: string; - show: boolean; -} - -@Component({ - selector: 'dot-binary-field-preview', - imports: [ - ButtonModule, - SkeletonModule, - DotTempFileThumbnailComponent, - DialogModule, - DotMessagePipe, - DotFileSizeFormatPipe, - DotCopyButtonComponent - ], - providers: [DotResourceLinksService], - templateUrl: './dot-binary-field-preview.component.html', - styleUrls: ['./dot-binary-field-preview.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class DotBinaryFieldPreviewComponent implements OnInit, OnChanges { - @Input() contentlet: DotCMSContentlet; - @Input() tempFile: DotCMSTempFile; - @Input() editableImage: boolean; - @Input() fieldVariable: string; - @Input() disabled = false; - - @Output() editImage: EventEmitter = new EventEmitter(); - @Output() editFile: EventEmitter = new EventEmitter(); - @Output() removeFile: EventEmitter = new EventEmitter(); - - protected visibility = false; - protected isEditable = false; - protected readonly content = signal(''); - protected readonly resourceLinks = signal([]); - readonly #dotResourceLinksService = inject(DotResourceLinksService); - - get metadata(): DotFileMetadata { - return this.tempFile?.metadata ?? getFileMetadata(this.contentlet); - } - - get title(): string { - return this.contentlet?.fileName || this.metadata.name; - } - - get downloadLink(): string { - return `/contentAsset/raw-data/${this.contentlet.inode}/${this.fieldVariable}?byInode=true&force_download=true`; - } - - ngOnInit() { - if (this.contentlet) { - this.content.set(this.contentlet?.content); - this.fetchResourceLinks(); - } - } - - ngOnChanges({ tempFile, editableImage }: SimpleChanges): void { - if (editableImage) { - this.isEditable = this.isFileEditable(); - } - - if (tempFile?.currentValue) { - this.content.set(tempFile.currentValue.content); - } - } - - /** - * Emits event to remove the file - * - * @return {*} {void} - * @memberof DotBinaryFieldPreviewComponent - */ - onEdit(): void { - if (this.disabled) { - return; - } - - if (this.metadata.editableAsText) { - this.editFile.emit(); - - return; - } - - this.editImage.emit(); - } - - /** - * fetch the source links for the file - * - * @private - * @memberof DotBinaryFieldPreviewComponent - */ - private fetchResourceLinks(): void { - this.#dotResourceLinksService - .getFileResourceLinksByInode({ - fieldVariable: this.fieldVariable, - inode: this.contentlet.inode - }) - .pipe( - catchError(() => { - return of({ - configuredImageURL: '', - text: '', - versionPath: '', - idPath: '' - }); - }) - ) - .subscribe(({ configuredImageURL, text, versionPath, idPath }) => { - const fileLink = configuredImageURL - ? `${window.location.origin}${configuredImageURL}` - : ''; - - this.resourceLinks.set([ - { - key: 'FileLink', - value: fileLink, - show: true - }, - { - key: 'Resource-Link', - value: text, - show: this.contentlet.baseType === DotCMSBaseTypesContentTypes.FILEASSET - }, - { - key: 'VersionPath', - value: versionPath, - show: true - }, - { - key: 'IdPath', - value: idPath, - show: true - } - ]); - }); - } - - /** - * Downloads the file asset - * - * @memberof DotBinaryFieldPreviewComponent - */ - downloadAsset(): void { - if (this.disabled) { - return; - } - - window.open(this.downloadLink, '_self'); - } - - /** - * Check if the file is editable - * - * @return {*} {boolean} - * @memberof DotBinaryFieldPreviewComponent - */ - private isFileEditable(): boolean { - return this.metadata.editableAsText || this.isEditableImage(); - } - - /** - * Check if the file is an editable image - * - * @private - * @return {*} {boolean} - * @memberof DotBinaryFieldPreviewComponent - */ - private isEditableImage(): boolean { - return this.metadata.isImage && this.editableImage; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.html deleted file mode 100644 index 76f082bf1655..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- -
-
- - -
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.scss deleted file mode 100644 index 5ea444f1bb1e..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.scss +++ /dev/null @@ -1,55 +0,0 @@ -@use "variables" as *; -@use "dotcms-theme/utils/theme-variables" as *; - -:host { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - gap: $spacing-3; - height: 100%; - padding: $spacing-3; - - &.disabled { - opacity: 1; // Override the global disabled opacity - pointer-events: none; - } -} - -.icon-container { - border-radius: 50%; - padding: $spacing-3; - - .icon { - font-size: $font-size-xxl; - width: auto; - } - - &.info { - color: $color-palette-primary-500; - background: $color-palette-primary-200; - } - - &.error { - color: $color-alert-yellow; - background: $color-alert-yellow-light; - } - - &.disabled { - color: $field-disabled-color; - background: $field-disabled-bgcolor; - - .icon { - color: $field-disabled-color; - } - } -} - -.text { - text-align: center; - line-height: 140%; - - &.disabled { - color: $field-disabled-color; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.spec.ts deleted file mode 100644 index 161c4df1b04a..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { byTestId, createHostFactory, SpectatorHost } from '@ngneat/spectator'; -import { mockProvider } from '@ngneat/spectator/jest'; -import { MockComponent } from 'ng-mocks'; - -import { CommonModule } from '@angular/common'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotBinaryFieldUiMessageComponent } from './dot-binary-field-ui-message.component'; - -import { DotBinaryFieldEditorComponent } from '../dot-binary-field-editor/dot-binary-field-editor.component'; - -describe('DotBinaryFieldUiMessageComponent', () => { - let spectator: SpectatorHost; - const uiMessage = { - message: 'Drag and Drop File', - icon: 'pi pi-upload', - severity: 'info' - }; - - const createHost = createHostFactory({ - component: DotBinaryFieldUiMessageComponent, - imports: [ - CommonModule, - DotMessagePipe, - HttpClientTestingModule, - MockComponent(DotBinaryFieldEditorComponent) - ], - providers: [mockProvider(DotMessagePipe)] - }); - - const setupHost = async (disabled = false) => { - spectator = createHost( - ` - - `, - { - hostProps: { - uiMessage, - disabled - } - } - ); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - }; - - describe('default state', () => { - beforeEach(async () => { - await setupHost(); - }); - - it('should have a message, icon, and serverity', () => { - const messageText = spectator.query(byTestId('ui-message-span')).innerHTML; - const messageIconClass = spectator.query(byTestId('ui-message-icon')).className; - const messageIconContainer = spectator.query( - byTestId('ui-message-icon-container') - ).className; - - expect(messageText).toBe('Drag and Drop File'); - expect(messageIconClass).toBe('icon pi pi-upload'); - expect(messageIconContainer).toBe('icon-container info'); - }); - - it('should have a button', () => { - const button = spectator.query(byTestId('choose-file-btn')); - expect(button).toBeTruthy(); - }); - }); - - describe('when disabled', () => { - beforeEach(async () => { - await setupHost(true); - }); - - it('should add disabled class to host element', () => { - expect(spectator.element).toHaveClass('disabled'); - }); - - it('should add disabled class to icon container', () => { - const iconContainer = spectator.query(byTestId('ui-message-icon-container')); - expect(iconContainer).toHaveClass('disabled'); - }); - - it('should add disabled class to text element', () => { - const textElement = spectator.query('.text'); - expect(textElement).toHaveClass('disabled'); - }); - - it('should still display message content when disabled', () => { - const messageText = spectator.query(byTestId('ui-message-span')).innerHTML; - const messageIconClass = spectator.query(byTestId('ui-message-icon')).className; - const button = spectator.query(byTestId('choose-file-btn')); - - expect(messageText).toBe('Drag and Drop File'); - expect(messageIconClass).toBe('icon pi pi-upload'); - expect(button).toBeTruthy(); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.ts deleted file mode 100644 index 43a44807bf3c..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-ui-message/dot-binary-field-ui-message.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, Component, HostBinding, Input } from '@angular/core'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { UiMessageI } from '../../interfaces'; - -@Component({ - selector: 'dot-binary-field-ui-message', - imports: [DotMessagePipe, NgClass], - templateUrl: './dot-binary-field-ui-message.component.html', - styleUrls: ['./dot-binary-field-ui-message.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotBinaryFieldUiMessageComponent { - @Input() uiMessage: UiMessageI; - - /** - * Whether the component is disabled. - * - * @memberof DotBinaryFieldUiMessageComponent - */ - @Input() - @HostBinding('class.disabled') - disabled = false; -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.html deleted file mode 100644 index b181203abe98..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.html +++ /dev/null @@ -1,54 +0,0 @@ -@if (vm$ | async; as vm) { -
-
- - -
- @if (!vm.error) { - - } @else { - - {{ vm.error | dm: [acceptTypes] }} - - } -
-
-
- -
- @if (!vm.isLoading) { - - } @else { - - } -
-
-
-} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.scss deleted file mode 100644 index b82643dbfdc7..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.scss +++ /dev/null @@ -1,37 +0,0 @@ -@use "variables" as *; - -:host ::ng-deep { - display: block; - width: 32rem; - - .p-button { - width: 100%; - } - - .error-messsage__container { - min-height: $spacing-4; // Fix height to avoid jumping - } -} - -.url-mode__form { - display: flex; - flex-direction: column; - gap: $spacing-3; - justify-content: center; - align-items: flex-start; -} - -.url-mode__input-container { - width: 100%; - display: flex; - gap: $spacing-1; - flex-direction: column; -} - -.url-mode__actions { - width: 100%; - display: flex; - gap: $spacing-1; - align-items: center; - justify-content: flex-end; -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.spec.ts deleted file mode 100644 index d28bc249a453..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { expect, it } from '@jest/globals'; -import { Spectator, byTestId, createComponentFactory } from '@ngneat/spectator'; - -import { By } from '@angular/platform-browser'; - -import { ButtonModule } from 'primeng/button'; - -import { DotMessageService, DotUploadService } from '@dotcms/data-access'; - -import { DotBinaryFieldUrlModeComponent } from './dot-binary-field-url-mode.component'; -import { DotBinaryFieldUrlModeStore } from './store/dot-binary-field-url-mode.store'; - -import { DotBinaryFieldValidatorService } from '../../service/dot-binary-field-validator/dot-binary-field-validator.service'; -import { TEMP_FILE_MOCK } from '../../store/binary-field.store.spec'; -import { CONTENTTYPE_FIELDS_MESSAGE_MOCK } from '../../utils/mock'; - -describe('DotBinaryFieldUrlModeComponent', () => { - let spectator: Spectator; - let component: DotBinaryFieldUrlModeComponent; - - let store: DotBinaryFieldUrlModeStore; - - const createComponent = createComponentFactory({ - component: DotBinaryFieldUrlModeComponent, - imports: [ButtonModule], - componentProviders: [DotBinaryFieldUrlModeStore], - providers: [ - DotBinaryFieldValidatorService, - { - provide: DotUploadService, - useValue: { - uploadFile: ({ file }) => { - return new Promise((resolve) => { - if (file) { - resolve(TEMP_FILE_MOCK); - } - }); - } - } - }, - { - provide: DotMessageService, - useValue: CONTENTTYPE_FIELDS_MESSAGE_MOCK - } - ] - }); - - beforeEach(() => { - spectator = createComponent({ - detectChanges: false - }); - - component = spectator.component; - store = spectator.inject(DotBinaryFieldUrlModeStore, true); - spectator.detectChanges(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should have a form with url field', () => { - expect(spectator.query(byTestId('url-input'))).not.toBeNull(); - }); - - it('should have a button to import', () => { - expect(spectator.query(byTestId('import-button'))).not.toBeNull(); - }); - - describe('Actions', () => { - it('should upload file by url form when click on import button', async () => { - const spy = jest.spyOn(component.tempFileUploaded, 'emit'); - const spyUploadFileByUrl = jest.spyOn(store, 'uploadFileByUrl'); - const importButton = spectator.query('[data-testId="import-button"] button'); - const form = spectator.component.form; - - form.setValue({ url: 'http://dotcms.com' }); - spectator.click(importButton); - - expect(spy).toHaveBeenCalledWith(TEMP_FILE_MOCK); - expect(spectator.component.form.valid).toBeTruthy(); - expect(spyUploadFileByUrl).toHaveBeenCalled(); - }); - - it('should cancel when click on cancel button', () => { - const spyCancel = jest.spyOn(spectator.component.cancel, 'emit'); - const cancelButton = spectator.query('[data-testId="cancel-button"] button'); - - spectator.click(cancelButton); - - expect(spyCancel).toHaveBeenCalled(); - }); - - it('should show loading button when isLoading', async () => { - store.setIsLoading(true); - await spectator.fixture.whenStable(); - spectator.detectChanges(); - - const loadingButton = spectator.query(byTestId('loading-button')); - const importButton = spectator.query(byTestId('import-button')); - - expect(loadingButton).toBeTruthy(); - expect(importButton).not.toBeTruthy(); - }); - }); - - describe('validation', () => { - it('should be invalid when url is empty', () => { - spectator.component.form.setValue({ url: '' }); - expect(spectator.component.form.valid).toBe(false); - }); - - it('should be invalid when url is not valid', () => { - spectator.component.form.setValue({ url: 'Not a url' }); - expect(spectator.component.form.valid).toBe(false); - }); - - it('should be valid when url is valid', () => { - spectator.component.form.setValue({ url: 'http://dotcms.com' }); - expect(spectator.component.form.valid).toBeTruthy(); - }); - - it('should show error when value is empty and user is trying to upload file ', async () => { - const button = spectator.query('[data-testId="import-button"] button'); - const form = spectator.component.form; - - form.setValue({ url: '' }); - spectator.click(button); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - - const fieldMessage = spectator.fixture.debugElement.query( - By.css('dot-field-validation-message') - ); - const error = fieldMessage.componentInstance.defaultMessage; - - expect(spectator.component.form.invalid).toBeTruthy(); - expect(error).toBe('The URL you requested is not valid. Please try again.'); - }); - }); - - describe('template', () => { - it('should show error message when url is invalid', () => { - const input = spectator.query(byTestId('url-input')); - - input.focus(); // to trigger touched - input.value = 'Not a url'; // to trigger invalid - input.blur(); // to trigger dirty - - expect(spectator.query(byTestId('error-message'))).toBeTruthy(); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.ts deleted file mode 100644 index 175c0fe7a4fb..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/dot-binary-field-url-mode.component.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Subject } from 'rxjs'; - -import { AsyncPipe } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - OnDestroy, - OnInit, - Output, - inject -} from '@angular/core'; -import { - FormGroup, - FormControl, - FormsModule, - ReactiveFormsModule, - Validators -} from '@angular/forms'; - -import { ButtonModule } from 'primeng/button'; -import { InputTextModule } from 'primeng/inputtext'; - -import { filter, takeUntil, tap } from 'rxjs/operators'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DotFieldValidationMessageComponent, DotMessagePipe } from '@dotcms/ui'; - -import { DotBinaryFieldUrlModeStore } from './store/dot-binary-field-url-mode.store'; - -import { DotBinaryFieldValidatorService } from '../../service/dot-binary-field-validator/dot-binary-field-validator.service'; - -@Component({ - selector: 'dot-binary-field-url-mode', - imports: [ - FormsModule, - ReactiveFormsModule, - ButtonModule, - InputTextModule, - DotMessagePipe, - DotFieldValidationMessageComponent, - AsyncPipe - ], - providers: [DotBinaryFieldUrlModeStore], - templateUrl: './dot-binary-field-url-mode.component.html', - styleUrls: ['./dot-binary-field-url-mode.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotBinaryFieldUrlModeComponent implements OnInit, OnDestroy { - @Output() tempFileUploaded: EventEmitter = new EventEmitter(); - @Output() cancel: EventEmitter = new EventEmitter(); - - private readonly store = inject(DotBinaryFieldUrlModeStore); - private readonly dotBinaryFieldValidatorService = inject(DotBinaryFieldValidatorService); - - // Form - private readonly validators = [ - Validators.required, - Validators.pattern(/^(ftp|http|https):\/\/[^ "]+$/) - ]; - readonly form = new FormGroup({ - url: new FormControl('', this.validators) - }); - - // Observables - readonly vm$ = this.store.vm$.pipe(tap(({ isLoading }) => this.toggleForm(isLoading))); - readonly tempFileChanged$ = this.store.tempFile$; - - private readonly destroy$ = new Subject(); - private abortController: AbortController; - - get acceptTypes(): string { - return this.dotBinaryFieldValidatorService.accept.join(','); - } - - ngOnInit(): void { - this.tempFileChanged$ - .pipe( - takeUntil(this.destroy$), - filter((tempFile) => tempFile !== null) - ) - .subscribe((tempFile) => { - this.tempFileUploaded.emit(tempFile); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - this.abortController?.abort(); // Abort fetch request if component is destroyed - } - - /** - * Submit form - * - * @return {*} {void} - * @memberof DotBinaryFieldUrlModeComponent - */ - onSubmit(): void { - if (this.form.invalid) { - return; - } - - const url = this.form.get('url').value; - this.abortController = new AbortController(); - - this.store.uploadFileByUrl({ url, signal: this.abortController.signal }); - this.form.reset({ url }); // Reset form to initial state - } - - /** - * Cancel upload - * - * @memberof DotBinaryFieldUrlModeComponent - */ - cancelUpload(): void { - this.abortController?.abort(); - // TODO: The 'emit' function requires a mandatory void argument - this.cancel.emit(); - } - - /** - * Handle focus event and clear server error message - * - * @memberof DotBinaryFieldUrlModeComponent - */ - handleFocus(): void { - this.store.setError(''); // Clear server error message when user focus on input - } - - private toggleForm(isLoading: boolean): void { - isLoading ? this.form.disable() : this.form.enable(); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.spec.ts deleted file mode 100644 index edb0c7398876..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { expect, describe, jest } from '@jest/globals'; -import { SpectatorService, createServiceFactory } from '@ngneat/spectator'; - -import { skip } from 'rxjs/operators'; - -import { DotUploadService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - -import { - DotBinaryFieldUrlModeState, - DotBinaryFieldUrlModeStore -} from './dot-binary-field-url-mode.store'; - -import { DotBinaryFieldValidatorService } from '../../../service/dot-binary-field-validator/dot-binary-field-validator.service'; - -const INITIAL_STATE: DotBinaryFieldUrlModeState = { - tempFile: null, - isLoading: false, - error: '' -}; - -export const TEMP_FILE_MOCK: DotCMSTempFile = { - fileName: 'image.png', - folder: '/images', - id: '12345', - image: true, - length: 1000, - referenceUrl: '/reference/url', - thumbnailUrl: 'image.png', - mimeType: 'mimeType' -}; - -describe('DotBinaryFieldUrlModeStore', () => { - let spectator: SpectatorService; - let store: DotBinaryFieldUrlModeStore; - let dotBinaryFieldValidatorService: DotBinaryFieldValidatorService; - - let dotUploadService: DotUploadService; - let initialState; - - const createStoreService = createServiceFactory({ - service: DotBinaryFieldUrlModeStore, - providers: [ - DotBinaryFieldValidatorService, - { - provide: DotUploadService, - useValue: { - uploadFile: ({ file }) => { - return new Promise((resolve) => { - if (file) { - resolve(TEMP_FILE_MOCK); - } - }); - } - } - } - ] - }); - - beforeEach(() => { - spectator = createStoreService(); - dotUploadService = spectator.inject(DotUploadService); - store = spectator.inject(DotBinaryFieldUrlModeStore); - dotBinaryFieldValidatorService = spectator.inject(DotBinaryFieldValidatorService); - dotBinaryFieldValidatorService.setMaxFileSize(1048576); - - store.setState(INITIAL_STATE); - store.state$.subscribe((state) => { - initialState = state; - }); - }); - - it('should set initial state', () => { - expect(initialState).toEqual(INITIAL_STATE); - }); - - describe('Updaters', () => { - it('should set TempFile', (done) => { - store.setTempFile(TEMP_FILE_MOCK); - - store.tempFile$.subscribe((tempFile) => { - expect(tempFile).toEqual(TEMP_FILE_MOCK); - done(); - }); - }); - - it('should set isLoading', (done) => { - store.setIsLoading(true); - - store.vm$.subscribe((state) => { - expect(state.isLoading).toBeTruthy(); - done(); - }); - }); - - it('should set error and isLoading to false', (done) => { - store.setIsLoading(true); // Set isLoading to true - store.setError('Request Error'); // Set error and isLoading to false - - // Skip setIsLoading - store.vm$.subscribe((state) => { - expect(state.error).toBe('Request Error'); - expect(state.isLoading).toBeFalsy(); - done(); - }); - }); - }); - - describe('Actions', () => { - describe('handleUploadFile', () => { - it('should set tempFile and loading to false', (done) => { - const spySetIsLoading = jest.spyOn(store, 'setIsLoading'); - const abortController = new AbortController(); - - store.uploadFileByUrl({ - url: 'url', - signal: abortController.signal - }); - - // Skip initial state - store.tempFile$.pipe(skip(1)).subscribe((tempFile) => { - expect(tempFile).toEqual(TEMP_FILE_MOCK); - done(); - }); - - expect(spySetIsLoading).toHaveBeenCalledWith(true); - }); - - it('should called tempFile API with 1MB', (done) => { - const spyOnUploadService = jest.spyOn(dotUploadService, 'uploadFile'); - - // 1MB - store.setMaxFileSize(1048576); - const abortController = new AbortController(); - - store.uploadFileByUrl({ - url: 'url', - signal: abortController.signal - }); - - // Skip initial state - store.tempFile$.pipe(skip(1)).subscribe(() => { - expect(spyOnUploadService).toHaveBeenCalledWith({ - file: 'url', - maxSize: '1MB', - signal: abortController.signal - }); - done(); - }); - }); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.store.ts deleted file mode 100644 index 793e56ea8ada..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-url-mode/store/dot-binary-field-url-mode.store.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { ComponentStore } from '@ngrx/component-store'; -import { tapResponse } from '@ngrx/operators'; -import { Observable, from } from 'rxjs'; - -import { Injectable, inject } from '@angular/core'; - -import { switchMap, tap } from 'rxjs/operators'; - -import { DotUploadService } from '@dotcms/data-access'; -import { DotCMSTempFile, DotHttpErrorResponse } from '@dotcms/dotcms-models'; - -import { DotBinaryFieldValidatorService } from '../../../service/dot-binary-field-validator/dot-binary-field-validator.service'; - -export interface DotBinaryFieldUrlModeState { - tempFile: DotCMSTempFile; - isLoading: boolean; - error: string; -} - -@Injectable() -export class DotBinaryFieldUrlModeStore extends ComponentStore { - private readonly dotUploadService = inject(DotUploadService); - private readonly dotBinaryFieldValidatorService = inject(DotBinaryFieldValidatorService); - - private _maxFileSize: number; - private _accept: string[]; - - readonly vm$ = this.select((state) => state); - - readonly tempFile$ = this.select(({ tempFile }) => tempFile); - - readonly error$ = this.select(({ error }) => error); - - constructor() { - super({ - tempFile: null, - isLoading: false, - error: '' - }); - - this.setMaxFileSize(this.dotBinaryFieldValidatorService.maxFileSize); - } - - // Update state - readonly setTempFile = this.updater((state, tempFile: DotCMSTempFile) => { - return { - ...state, - tempFile, - isLoading: false, - error: '' - }; - }); - - readonly setIsLoading = this.updater((state, isLoading: boolean) => { - return { - ...state, - isLoading - }; - }); - - readonly setError = this.updater((state, error: string) => { - return { - ...state, - isLoading: false, - error - }; - }); - - // Actions - readonly uploadFileByUrl = this.effect( - ( - data$: Observable<{ - url: string; - signal: AbortSignal; - }> - ) => { - return data$.pipe( - tap(() => this.setIsLoading(true)), - switchMap(({ url, signal }) => this.uploadTempFile(url, signal)) - ); - } - ); - - private uploadTempFile(file: File | string, signal: AbortSignal): Observable { - return from( - this.dotUploadService.uploadFile({ - file, - maxSize: this._maxFileSize ? `${this._maxFileSize}MB` : '', - signal: signal - }) - ).pipe( - tapResponse({ - next: (tempFile: DotCMSTempFile) => { - if (!this.isValidType(tempFile)) { - this.setError( - 'dot.binary.field.import.from.url.error.file.not.supported.message' - ); - return; - } - this.setTempFile(tempFile); - }, - error: (error: DotHttpErrorResponse) => { - if (signal.aborted) { - this.setIsLoading(false); - return; - } - this.setError(error.message); - } - }) - ); - } - - setMaxFileSize(bytes: number) { - this._maxFileSize = this._maxFileSize = bytes / (1024 * 1024); - } - - /** - * Validate file type - * - * @private - * @return {*} {boolean} - * @memberof DotBinaryFieldUrlModeStore - */ - private isValidType(tempFile: DotCMSTempFile): boolean { - const { fileName, mimeType } = tempFile; - const extension = fileName.split('.').pop(); - - return this.dotBinaryFieldValidatorService.isValidType({ - extension, - mimeType - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html deleted file mode 100644 index 658276264aea..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.html +++ /dev/null @@ -1,37 +0,0 @@ -@let field = $field(); -@let showLabel = $showLabel(); -@let fieldHasError = $hasError(); - - - @if (showLabel) { - - {{ field.name }} - - } - - - - - @if (fieldHasError) { -
- @if (isRequired) { - {{ 'dot.edit.content.form.field.required' | dm }} - } -
- } - - @if (!fieldHasError && field.hint) { -
- - {{ field.hint }} - -
- } -
-
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts deleted file mode 100644 index 042158024ffc..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/components/dot-binary-field-wrapper/dot-binary-field-wrapper.component.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - DestroyRef, - inject, - input, - OnInit, - output -} from '@angular/core'; -import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; - -import { DialogService } from 'primeng/dynamicdialog'; - -import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotCardFieldContentComponent } from '../../../dot-card-field/components/dot-card-field-content.component'; -import { DotCardFieldFooterComponent } from '../../../dot-card-field/components/dot-card-field-footer.component'; -import { DotCardFieldLabelComponent } from '../../../dot-card-field/components/dot-card-field-label/dot-card-field-label.component'; -import { DotCardFieldComponent } from '../../../dot-card-field/dot-card-field.component'; -import { BaseWrapperField } from '../../../shared/base-wrapper-field'; -import { DotEditContentBinaryFieldComponent } from '../../dot-edit-content-binary-field.component'; -import { DotLegacyImageEditorLauncherService } from '../../service/dot-legacy-image-editor/dot-legacy-image-editor-launcher.service'; - -/** - * Wrapper for binary fields in the content editor card layout. - * - * Enables the legacy image editor by wiring {@link DotLegacyImageEditorLauncherService} - * to the binary field web component lifecycle. - */ -@Component({ - selector: 'dot-binary-field-wrapper', - imports: [ - ReactiveFormsModule, - DotCardFieldComponent, - DotCardFieldContentComponent, - DotCardFieldFooterComponent, - DotCardFieldLabelComponent, - DotMessagePipe, - DotEditContentBinaryFieldComponent - ], - providers: [DialogService, DotLegacyImageEditorLauncherService], - templateUrl: './dot-binary-field-wrapper.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - viewProviders: [ - { - provide: ControlContainer, - useFactory: () => inject(ControlContainer, { skipSelf: true, optional: true }) - } - ] -}) -export class DotBinaryFieldWrapperComponent extends BaseWrapperField implements OnInit { - readonly #legacyImageEditorLauncher = inject(DotLegacyImageEditorLauncherService); - readonly #destroyRef = inject(DestroyRef); - - /** - * A signal that holds the field. - * It is used to display the field in the binary field wrapper component. - */ - $field = input.required({ - alias: 'field' - }); - /** - * A signal that holds the contentlet. - * It is used to display the contentlet in the binary field wrapper component. - */ - $contentlet = input.required({ - alias: 'contentlet' - }); - /** - * An output signal that emits when the value is updated. - * It is used to display the value in the binary field wrapper component. - */ - valueUpdated = output<{ value: string; fileName: string }>(); - - /** - * Starts listening for legacy image editor events for this field and cleans up on destroy. - */ - ngOnInit(): void { - this.#legacyImageEditorLauncher.listen(this.$field().variable); - - this.#destroyRef.onDestroy(() => { - this.#legacyImageEditorLauncher.stopListening(); - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.html deleted file mode 100644 index 20079d303e41..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.html +++ /dev/null @@ -1,139 +0,0 @@ -@if (vm$ | async; as vm) { -
- @switch (vm.status) { - @case (BinaryFieldStatus.INIT) { -
- - - - - - -
- -
- @if (systemOptions().allowURLImport) { - - } - @if (systemOptions().allowCodeWrite) { - - } - @if (systemOptions().allowGenerateImg) { - - - - - - - - - - } -
- } - @case (BinaryFieldStatus.UPLOADING) { - - } - @case (BinaryFieldStatus.PREVIEW) { - - } - } - - - @switch (vm.mode) { - @case (BinaryFieldMode.URL) { - - } - @case (BinaryFieldMode.EDITOR) { - - } - } - -
-} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.scss deleted file mode 100644 index 463df1686b00..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.scss +++ /dev/null @@ -1,113 +0,0 @@ -@use "variables" as *; - -:host { - display: block; - container-type: inline-size; - container-name: binaryField; -} - -.binary-field__container { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - border-radius: var(--p-inputtext-border-radius); - border: 1px solid var(--p-inputtext-border-color); - padding: $spacing-1; - height: 14.4rem; - min-width: 12.5rem; - transition: - border-color var(--p-inputtext-transition-duration), - box-shadow var(--p-inputtext-transition-duration); - - &:has(.binary-field__actions:empty) { - gap: 0; // Remove gap when there are no actions, is cleaner than removing the div by logic - } -} - -.binary-field__container:not(.binary-field__container--disabled):hover { - border-color: var(--p-inputtext-hover-border-color); -} - -:host.ng-invalid.ng-touched .binary-field__container { - border-color: var(--p-inputtext-invalid-border-color); -} - -.binary-field__container--uploading { - border: 1px dashed var(--p-inputtext-border-color); -} - -.binary-field__actions { - display: flex; - flex-direction: column; - gap: $spacing-3; - justify-content: center; - align-items: flex-start; - - &:empty { - display: none; - } - - .label-ai { - text-transform: none; - font-size: $font-size-sm; - } - - .p-button { - display: inline-flex; - user-select: none; - align-items: center; - vertical-align: bottom; - text-align: center; - } -} - -.binary-field__drop-zone { - border: $field-border-size dashed $input-border-color; - border-radius: var(--p-inputtext-border-radius); - height: 100%; - flex: 1; - overflow: auto; - margin-right: $spacing-1; -} - -.binary-field__drop-zone-btn { - border: none; - background: none; - color: $color-palette-primary-500; - text-decoration: underline; - font-size: $font-size-md; - font-family: $font-default; - padding: revert; - cursor: pointer; - - &:disabled { - color: $button-text-color-disabled; - } -} - -.binary-field__drop-zone--active { - border-radius: var(--p-inputtext-border-radius); - border-color: $color-palette-secondary-500; - background: $white; - box-shadow: $shadow-l; -} - -input[type="file"] { - display: none; -} - -@container binaryField (max-width: 306px) { - .binary-field__container--empty { - height: auto; - flex-direction: column; - justify-content: center; - align-items: flex-start; - } - - .binary-field__drop-zone { - width: 100%; - margin: 0; - margin-bottom: $spacing-1; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts deleted file mode 100644 index d9d03b3104b0..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.spec.ts +++ /dev/null @@ -1,888 +0,0 @@ -import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; -import { - byTestId, - createComponentFactory, - createHostFactory, - Spectator, - SpectatorHost, - SpyObject -} from '@ngneat/spectator/jest'; -import { of } from 'rxjs'; - -import { provideHttpClient } from '@angular/common/http'; -import { Component, NgZone } from '@angular/core'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { - ControlContainer, - FormControl, - FormGroup, - FormGroupDirective, - ReactiveFormsModule, - Validators -} from '@angular/forms'; -import { By } from '@angular/platform-browser'; - -import { Button, ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; -import { DialogService } from 'primeng/dynamicdialog'; - -import { - DotAiService, - DotLicenseService, - DotMessageService, - DotUploadService -} from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DropZoneErrorType, DropZoneFileEvent } from '@dotcms/ui'; -import { dotcmsContentletMock } from '@dotcms/utils-testing'; - -import { DotBinaryFieldPreviewComponent } from './components/dot-binary-field-preview/dot-binary-field-preview.component'; -import { DotEditContentBinaryFieldComponent } from './dot-edit-content-binary-field.component'; -import { BinaryFieldMode, BinaryFieldStatus } from './interfaces'; -import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service'; -import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service'; -import { DotBinaryFieldStore } from './store/binary-field.store'; -import { getUiMessage } from './utils/binary-field-utils'; -import { CONTENTTYPE_FIELDS_MESSAGE_MOCK, fileMetaData } from './utils/mock'; - -import { BINARY_FIELD_MOCK, createFormGroupDirectiveMock } from '../../utils/mocks'; - -const TEMP_FILE_MOCK: DotCMSTempFile = { - fileName: 'image.png', - folder: '/images', - id: '123456', - image: true, - length: 1000, - referenceUrl: - 'https://images.unsplash.com/photo-1575936123452-b67c3203c357?auto=format&fit=crop&q=80&w=1000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aW1hZ2V8ZW58MHx8MHx8fDA%3D', - thumbnailUrl: 'image.png', - mimeType: 'mimeType', - metadata: fileMetaData -}; - -const file = new File([''], 'filename'); -const validity = { - valid: true, - fileTypeMismatch: false, - maxFileSizeExceeded: false, - multipleFilesDropped: false, - errorsType: [DropZoneErrorType.FILE_TYPE_MISMATCH] -}; - -const DROP_ZONE_FILE_EVENT: DropZoneFileEvent = { - file, - validity -}; - -const MOCK_DOTCMS_FILE = { - ...dotcmsContentletMock, - binaryField: '12345', - baseType: 'CONTENT', - binaryFieldMetaData: fileMetaData -}; - -describe('DotEditContentBinaryFieldComponent', () => { - let spectator: Spectator; - let store: DotBinaryFieldStore; - - let dotBinaryFieldEditImageService: SpyObject; - let dotAiService: DotAiService; - let ngZone: NgZone; - - const createComponent = createComponentFactory({ - component: DotEditContentBinaryFieldComponent, - componentProviders: [ - DotBinaryFieldStore, - DotBinaryFieldEditImageService, - DotAiService, - DialogService - ], - componentViewProviders: [ - { provide: ControlContainer, useValue: createFormGroupDirectiveMock() } - ], - providers: [ - provideHttpClient(), - DotBinaryFieldValidatorService, - { - provide: DotLicenseService, - useValue: { - isEnterprise: () => of(true) - } - }, - { - provide: DotUploadService, - useValue: { - uploadFile: ({ file }) => { - return new Promise((resolve) => { - if (file) { - resolve(TEMP_FILE_MOCK); - } - }); - } - } - }, - { - provide: DotMessageService, - useValue: CONTENTTYPE_FIELDS_MESSAGE_MOCK - }, - FormGroupDirective - ] - }); - - beforeEach(() => { - // This spec relies on async Angular stabilization (whenStable). - // In large Jest runs, other suites can leave fake timers enabled in the same worker, - // which can cause these async setups to hang/flap. Force real timers for isolation. - jest.useRealTimers(); - - spectator = createComponent({ - detectChanges: false, - props: { - field: { - ...BINARY_FIELD_MOCK - }, - contentlet: null - } - }); - store = spectator.inject(DotBinaryFieldStore, true); - dotBinaryFieldEditImageService = spectator.inject(DotBinaryFieldEditImageService, true); - dotAiService = spectator.inject(DotAiService, true); - ngZone = spectator.inject(NgZone); - }); - - it('shouldnt show url import button if not setted in settings', () => { - const importFromURLButton = spectator.query(byTestId('action-url-btn')); - - expect(importFromURLButton).toBeNull(); - }); - - it('shouldnt show code editor button if not setted in settings', async () => { - const codeEditorButton = spectator.query(byTestId('action-editor-btn')); - - expect(codeEditorButton).toBeNull(); - }); - - it('should emit temp file', () => { - const spyEmit = jest.spyOn(spectator.component.valueUpdated, 'emit'); - spectator.detectChanges(); - store.setTempFile(TEMP_FILE_MOCK); - expect(spyEmit).toHaveBeenCalledWith({ - value: TEMP_FILE_MOCK.id, - fileName: TEMP_FILE_MOCK.fileName - }); - }); - - it('should not emit new value is is equal to current value', () => { - spectator.setInput('contentlet', MOCK_DOTCMS_FILE); - const spyEmit = jest.spyOn(spectator.component.valueUpdated, 'emit'); - spectator.component.writeValue(MOCK_DOTCMS_FILE.binaryField); - store.setValue(MOCK_DOTCMS_FILE.binaryField); - spectator.detectChanges(); - expect(spyEmit).not.toHaveBeenCalled(); - }); - - describe('Dropzone', () => { - beforeEach(async () => { - spectator.setInput('contentlet', MOCK_DOTCMS_FILE); - spectator.detectChanges(); - store.setStatus(BinaryFieldStatus.INIT); - await spectator.fixture.whenStable(); - spectator.detectChanges(); - }); - - it('should show dropzone when status is INIT', () => { - expect(spectator.query('dot-drop-zone')).toBeTruthy(); - }); - - it('should handle file drop', () => { - const spyUploadFile = jest.spyOn(store, 'handleUploadFile'); - const spyInvalidFile = jest.spyOn(store, 'invalidFile'); - const dropZone = spectator.fixture.debugElement.query(By.css('dot-drop-zone')); - - dropZone.triggerEventHandler('fileDropped', DROP_ZONE_FILE_EVENT); - - expect(spyUploadFile).toHaveBeenCalledWith(DROP_ZONE_FILE_EVENT.file); - expect(spyInvalidFile).not.toHaveBeenCalled(); - }); - - it('should handle file drop error', () => { - const spyUploadFile = jest.spyOn(store, 'handleUploadFile'); - const spyInvalidFile = jest.spyOn(store, 'invalidFile'); - const dropZone = spectator.fixture.debugElement.query(By.css('dot-drop-zone')); - - dropZone.triggerEventHandler('fileDropped', { - ...DROP_ZONE_FILE_EVENT, - validity: { - ...DROP_ZONE_FILE_EVENT.validity, - fileTypeMismatch: true, - valid: false - } - }); - - expect(spyInvalidFile).toHaveBeenCalledWith( - getUiMessage('FILE_TYPE_MISMATCH', 'image/*, .html, .ts') - ); - expect(spyUploadFile).not.toHaveBeenCalled(); - }); - - it('should handle file dragover', () => { - const dropZone = spectator.fixture.debugElement.query(By.css('dot-drop-zone')); - const spyDropZoneActive = jest.spyOn(store, 'setDropZoneActive'); - dropZone.triggerEventHandler('fileDragOver', {}); - - expect(spyDropZoneActive).toHaveBeenCalledWith(true); - }); - - it('should handle file dragleave', () => { - const dropZone = spectator.fixture.debugElement.query(By.css('dot-drop-zone')); - const spyDropZoneActive = jest.spyOn(store, 'setDropZoneActive'); - dropZone.triggerEventHandler('fileDragLeave', {}); - - expect(spyDropZoneActive).toHaveBeenCalledWith(false); - }); - - it('should open file picker when click on choose file button', () => { - const spyOpenFilePicker = jest.spyOn(spectator.component, 'openFilePicker'); - const spyInputFile = jest.spyOn(spectator.component.inputFile.nativeElement, 'click'); - const chooseFile = spectator.query(byTestId('choose-file-btn')) as HTMLButtonElement; - chooseFile.click(); - expect(chooseFile.getAttribute('type')).toBe('button'); - expect(spyOpenFilePicker).toHaveBeenCalled(); - expect(spyInputFile).toHaveBeenCalled(); - }); - - it('should handle file selection', () => { - const spyUploadFile = jest.spyOn(store, 'handleUploadFile'); - const inputElement = spectator.fixture.debugElement.query( - By.css('[data-testId="binary-field__file-input"]') - ).nativeElement; - const file = new File(['test'], 'test.png', { type: 'image/png' }); - const event = new Event('change'); - Object.defineProperty(event, 'target', { value: { files: [file] } }); - inputElement.dispatchEvent(event); - - expect(spyUploadFile).toHaveBeenCalledWith(file); - }); - }); - - describe('Preview', () => { - beforeEach(async () => { - store.setStatus(BinaryFieldStatus.PREVIEW); - store.setTempFile(TEMP_FILE_MOCK); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - }); - - it('should remove file and set INIT status when remove file ', async () => { - const spyRemoveFile = jest.spyOn(store, 'removeFile'); - const dotBinaryPreviewFile = spectator.fixture.debugElement.query( - By.css('[data-testId="preview"]') - ); - - dotBinaryPreviewFile.componentInstance.removeFile.emit(); - - store.vm$.subscribe((state) => { - expect(state).toEqual({ - ...state, - status: BinaryFieldStatus.INIT, - value: '', - contentlet: null, - tempFile: null - }); - }); - - spectator.detectChanges(); - await spectator.fixture.whenStable(); - - const dropZone = spectator.fixture.debugElement.query(By.css('dot-drop-zone')); - - expect(dropZone).toBeTruthy(); - expect(spyRemoveFile).toHaveBeenCalled(); - }); - - describe('Edit Image', () => { - it('should open edit image dialog when click on edit image button', () => { - spectator.detectChanges(); - const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor'); - spectator.triggerEventHandler(DotBinaryFieldPreviewComponent, 'editImage', null); - expect(spy).toHaveBeenCalled(); - }); - - it('should emit the tempId of the edited image', () => { - // Needed because the openImageEditor method is using a DOM custom event - ngZone.run( - fakeAsync(() => { - const spy = jest.spyOn(dotBinaryFieldEditImageService, 'openImageEditor'); - const spyTempFile = jest.spyOn(store, 'setFileFromTemp'); - const dotBinaryFieldPreviewComponent = spectator.fixture.debugElement.query( - By.css('dot-binary-field-preview') - ); - dotBinaryFieldPreviewComponent.triggerEventHandler('editImage'); - const customEvent = new CustomEvent( - `binaryField-tempfile-${BINARY_FIELD_MOCK.variable}`, - { - detail: { tempFile: TEMP_FILE_MOCK } - } - ); - document.dispatchEvent(customEvent); - - tick(1000); - - expect(spy).toHaveBeenCalled(); - expect(spyTempFile).toHaveBeenCalledWith(TEMP_FILE_MOCK); - }) - ); - }); - }); - }); - - describe('Template', () => { - beforeEach(() => { - spectator.detectChanges(); - }); - - it('should show dropzone when status is INIT', async () => { - store.setStatus(BinaryFieldStatus.INIT); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - expect(spectator.query(byTestId('dropzone'))).toBeTruthy(); - }); - - it('should show loading when status is UPLOADING', async () => { - store.setStatus(BinaryFieldStatus.UPLOADING); - spectator.detectChanges(); - await spectator.fixture.whenStable(); - expect(spectator.query(byTestId('loading'))).toBeTruthy(); - }); - - it('should show preview when status is PREVIEW', async () => { - store.setTempFile(TEMP_FILE_MOCK); - spectator.detectChanges(); - - await spectator.fixture.whenStable(); - - expect(spectator.query(byTestId('preview'))).toBeTruthy(); - }); - }); - - describe('systemOptions all false', () => { - beforeEach(() => { - const systemOptions = { - allowURLImport: false, - allowCodeWrite: false, - allowGenerateImg: false - }; - - const JSONString = JSON.stringify(systemOptions); - - const newField = { - ...BINARY_FIELD_MOCK, - fieldVariables: [ - ...BINARY_FIELD_MOCK.fieldVariables, - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '5df3f8fc49177c195740bcdc02ec2db7', - id: '1ff1ff05-b9fb-4239-ad3d-b2cfaa9a8406', - key: 'systemOptions', - value: JSONString - } - ] - }; - - spectator = createComponent({ - detectChanges: false, - props: { - field: newField, - contentlet: null - } - }); - }); - - it('should show url import button if not setted in settings', () => { - spectator.detectChanges(); - const importFromURLButton = spectator.query(byTestId('action-url-btn')); - - expect(importFromURLButton).toBeNull(); - }); - - it('should show code editor button if not setted in settings', async () => { - spectator.detectChanges(); - const codeEditorButton = spectator.query(byTestId('action-editor-btn')); - - expect(codeEditorButton).toBeNull(); - }); - - it('should show code ai button if not setted in settings', async () => { - spectator.detectChanges(); - const codeEditorButton = spectator.query(byTestId('action-ai-btn')); - - expect(codeEditorButton).toBeNull(); - }); - }); - - describe('Ai option', () => { - beforeEach(() => { - const systemOptions = { - allowURLImport: true, - allowCodeWrite: true, - allowGenerateImg: true - }; - - const JSONString = JSON.stringify(systemOptions); - - const newField = { - ...BINARY_FIELD_MOCK, - fieldVariables: [ - ...BINARY_FIELD_MOCK.fieldVariables, - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '5df3f8fc49177c195740bcdc02ec2db7', - id: '1ff1ff05-b9fb-4239-ad3d-b2cfaa9a8406', - key: 'systemOptions', - value: JSONString - } - ] - }; - - spectator = createComponent({ - detectChanges: false, - props: { - field: newField, - contentlet: null - } - }); - }); - - it('should show ai button', async () => { - spectator.detectChanges(); - const codeEditorButton = spectator.query(byTestId('action-ai-btn')); - expect(codeEditorButton).toBeTruthy(); - }); - - it('should AI button is disabled when plugin is not installed', async () => { - dotAiService.checkPluginInstallation = jest.fn().mockReturnValue(of(false)); - spectator.detectChanges(); - const buttons = spectator.queryAll(Button); - const aiBtn = buttons[2]; - expect(aiBtn.disabled).toBe(true); - expect(aiBtn.styleClass).toContain('pointer-events-auto'); - }); - }); - - describe('Dialog', () => { - beforeEach(async () => { - jest.spyOn(store, 'setFileFromContentlet').mockReturnValue(of(null).subscribe()); - - spectator.detectChanges(); - await spectator.fixture.whenStable(); - spectator.detectChanges(); - }); - - it('should open dialog with code component when click on edit button', async () => { - const spySetMode = jest.spyOn(store, 'setMode'); - const editorBtn = spectator.query(byTestId('action-editor-btn')) as HTMLButtonElement; - editorBtn.click(); - - spectator.detectChanges(); - await spectator.fixture.whenStable(); - - const editorElement = document.querySelector('[data-testid="editor-mode"]'); // This element is added to the body by the dialog - const isDialogOpen = spectator.fixture.componentInstance.openDialog; - - expect(editorElement).toBeTruthy(); - expect(isDialogOpen).toBeTruthy(); - expect(spySetMode).toHaveBeenCalledWith(BinaryFieldMode.EDITOR); - }); - - it('should open dialog with url component when click on url button', async () => { - const spySetMode = jest.spyOn(store, 'setMode'); - const urlBtn = spectator.query(byTestId('action-url-btn')); - urlBtn.click(); - - spectator.detectChanges(); - await spectator.fixture.whenStable(); - - const urlElement = document.querySelector('[data-testid="url-mode"]'); // This element is added to the body by the dialog - const isDialogOpen = spectator.fixture.componentInstance.openDialog; - - expect(urlElement).toBeTruthy(); - expect(isDialogOpen).toBeTruthy(); - expect(spySetMode).toHaveBeenCalledWith(BinaryFieldMode.URL); - }); - }); - - describe('Set File', () => { - describe('Contentlet - BaseTyp FILEASSET', () => { - it('should set the correct file asset', () => { - const spy = jest - .spyOn(store, 'setFileFromContentlet') - .mockReturnValue(of(null).subscribe()); - const mock = { - ...MOCK_DOTCMS_FILE, - baseType: 'FILEASSET', - metaData: fileMetaData - }; - spectator.setInput('contentlet', mock); - spectator.detectChanges(); - expect(spy).toHaveBeenCalledWith({ - ...mock, - fieldVariable: BINARY_FIELD_MOCK.variable, - value: mock[BINARY_FIELD_MOCK.variable] - }); - }); - }); - - describe('Contentlet - BaseTyp CONTENT', () => { - it('should set the correct file asset', () => { - const spy = jest - .spyOn(store, 'setFileFromContentlet') - .mockReturnValue(of(null).subscribe()); - const variable = BINARY_FIELD_MOCK.variable; - spectator.setInput('contentlet', MOCK_DOTCMS_FILE); - spectator.detectChanges(); - expect(spy).toHaveBeenCalledWith({ - ...MOCK_DOTCMS_FILE, - fieldVariable: variable, - value: MOCK_DOTCMS_FILE[variable] - }); - }); - }); - - it('should not set file when metadata is not present', () => { - const spy = jest - .spyOn(store, 'setFileFromContentlet') - .mockReturnValue(of(null).subscribe()); - const mock = { - ...MOCK_DOTCMS_FILE, - binaryFieldMetaData: null - }; - spectator.setInput('contentlet', mock); - spectator.detectChanges(); - expect(spy).not.toHaveBeenCalled(); - }); - }); - - describe('Disabled State Management', () => { - beforeEach(() => { - spectator.detectChanges(); - store.setStatus(BinaryFieldStatus.INIT); - }); - - it('should set disabled state correctly through setDisabledState method', () => { - spectator.detectChanges(); - - // Initially not disabled - expect(spectator.component.$disabled()).toBe(false); - - // Set disabled - spectator.component.setDisabledState(true); - expect(spectator.component.$disabled()).toBe(true); - - // Set enabled - spectator.component.setDisabledState(false); - expect(spectator.component.$disabled()).toBe(false); - }); - - it('should disable file input when field is disabled', () => { - spectator.detectChanges(); - - const fileInput = spectator.query( - byTestId('binary-field__file-input') - ) as HTMLInputElement; - - // Initially not disabled - expect(fileInput.disabled).toBe(false); - - // Set disabled - spectator.component.setDisabledState(true); - spectator.detectChanges(); - expect(fileInput.disabled).toBe(true); - }); - - it('should disable action buttons when field is disabled', () => { - spectator.detectChanges(); - - // Set disabled - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - const urlBtnComponent = spectator.query(byTestId('action-url-btn')); - const editorBtnComponent = spectator.query(byTestId('action-editor-btn')); - - // Get the actual button elements inside the PrimeNG components - const actualUrlBtn = urlBtnComponent?.querySelector('button') as HTMLButtonElement; - const actualEditorBtn = editorBtnComponent?.querySelector( - 'button' - ) as HTMLButtonElement; - - // Verify buttons are actually disabled - expect(actualUrlBtn?.disabled).toBe(true); - expect(actualEditorBtn?.disabled).toBe(true); - }); - - it('should disable AI button when component is disabled', () => { - // Setup to show AI button - const systemOptions = { - allowURLImport: true, - allowCodeWrite: true, - allowGenerateImg: true - }; - - const JSONString = JSON.stringify(systemOptions); - const newField = { - ...BINARY_FIELD_MOCK, - fieldVariables: [ - ...BINARY_FIELD_MOCK.fieldVariables, - { - clazz: 'com.dotcms.contenttype.model.field.ImmutableFieldVariable', - fieldId: '5df3f8fc49177c195740bcdc02ec2db7', - id: '1ff1ff05-b9fb-4239-ad3d-b2cfaa9a8406', - key: 'systemOptions', - value: JSONString - } - ] - }; - - spectator = createComponent({ - detectChanges: false, - props: { - field: newField, - contentlet: null - } - }); - store = spectator.inject(DotBinaryFieldStore, true); - - spectator.detectChanges(); - - const aiBtnComponent = spectator.query(byTestId('action-ai-btn')); - - // Verify button exists - expect(aiBtnComponent).toBeTruthy(); - - // Set component disabled - should disable AI button - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - // Get the actual button element inside the PrimeNG component - const actualAiBtn = aiBtnComponent?.querySelector('button') as HTMLButtonElement; - - // Button should be disabled when component is disabled - expect(actualAiBtn?.disabled).toBe(true); - - // Re-enable component - spectator.component.setDisabledState(false); - spectator.detectChanges(); - - // Note: Button may still be disabled due to AI plugin check (initialValue: false) - // but the disabled state logic should be working correctly - expect(spectator.component.$disabled()).toBe(false); - }); - - it('should prevent file selection when disabled', () => { - const spyHandleUploadFile = jest.spyOn(store, 'handleUploadFile'); - - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - const inputElement = spectator.query( - byTestId('binary-field__file-input') - ) as HTMLInputElement; - const file = new File(['test'], 'test.png', { type: 'image/png' }); - const event = new Event('change'); - Object.defineProperty(event, 'target', { value: { files: [file] } }); - - inputElement.dispatchEvent(event); - - expect(spyHandleUploadFile).not.toHaveBeenCalled(); - }); - - it('should prevent file drop when disabled', () => { - const spyHandleUploadFile = jest.spyOn(store, 'handleUploadFile'); - - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - spectator.component.handleFileDrop(DROP_ZONE_FILE_EVENT); - - expect(spyHandleUploadFile).not.toHaveBeenCalled(); - }); - - it('should prevent opening dialogs when disabled', () => { - const spySetMode = jest.spyOn(store, 'setMode'); - - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - // Test each dialog mode - spectator.component.openDialog(BinaryFieldMode.URL); - spectator.component.openDialog(BinaryFieldMode.EDITOR); - spectator.component.openDialog(BinaryFieldMode.AI); - - expect(spySetMode).not.toHaveBeenCalled(); - expect(spectator.component.dialogOpen).toBe(false); - }); - - it('should prevent file picker opening when disabled', () => { - const spyInputClick = jest.spyOn(spectator.component.inputFile.nativeElement, 'click'); - - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - spectator.component.openFilePicker(); - - expect(spyInputClick).not.toHaveBeenCalled(); - }); - - it('should prevent file removal when disabled', () => { - const spyRemoveFile = jest.spyOn(store, 'removeFile'); - - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - spectator.component.removeFile(); - - expect(spyRemoveFile).not.toHaveBeenCalled(); - }); - - it('should add disabled CSS class to container when disabled', () => { - spectator.detectChanges(); - - const container = spectator.query('.binary-field__container'); - - // Initially not disabled - expect(container).not.toHaveClass('binary-field__container--disabled'); - - // Set disabled - spectator.component.setDisabledState(true); - spectator.detectChanges(); - expect(container).toHaveClass('binary-field__container--disabled'); - }); - - it('should pass disabled state to preview component when file is uploaded', () => { - // Set up preview state - store.setStatus(BinaryFieldStatus.PREVIEW); - store.setTempFile(TEMP_FILE_MOCK); - spectator.detectChanges(); - - // Set disabled - spectator.component.setDisabledState(true); - spectator.detectChanges(); - - const previewComponent = spectator.query(DotBinaryFieldPreviewComponent); - expect(previewComponent).toBeTruthy(); - expect(previewComponent.disabled).toBe(true); - }); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - jest.resetAllMocks(); - }); -}); - -/** - * - * @class MockFormComponent - */ -@Component({ - standalone: false, - selector: 'dot-custom-host', - template: '' -}) -class MockFormComponent { - field = BINARY_FIELD_MOCK; - contentlet = MOCK_DOTCMS_FILE; - form = new FormGroup({ - binaryField: new FormControl('') - }); -} - -describe('DotEditContentBinaryFieldComponent - ControlValueAccessor', () => { - let spectator: SpectatorHost; - const createHost = createHostFactory({ - component: DotEditContentBinaryFieldComponent, - host: MockFormComponent, - imports: [ - ButtonModule, - DialogModule, - MonacoEditorModule, - ReactiveFormsModule, - DotEditContentBinaryFieldComponent - ], - providers: [DotAiService, provideHttpClient()] - }); - - beforeEach(() => { - spectator = createHost(`
- -
`); - }); - - it('should set form value when binary file changes', () => { - // Call the onChange method from ControlValueAccessor - spectator.component.setTempFile(TEMP_FILE_MOCK); - const formValue = spectator.hostComponent.form.get('binaryField').value; // Get the form value - expect(formValue).toBe(TEMP_FILE_MOCK.id); // Check if the form value was set - }); -}); - -/** - * Mock host with required validation - */ -@Component({ - standalone: false, - selector: 'dot-required-host', - template: '' -}) -class MockRequiredFormComponent { - field = BINARY_FIELD_MOCK; - contentlet = null; - form = new FormGroup({ - binaryField: new FormControl('', Validators.required) - }); -} - -describe('DotEditContentBinaryFieldComponent - Validation', () => { - let spectator: SpectatorHost; - const createHost = createHostFactory({ - component: DotEditContentBinaryFieldComponent, - host: MockRequiredFormComponent, - imports: [ - ButtonModule, - DialogModule, - MonacoEditorModule, - ReactiveFormsModule, - DotEditContentBinaryFieldComponent - ], - providers: [DotAiService, provideHttpClient()] - }); - - beforeEach(() => { - spectator = createHost(`
- -
`); - }); - - it('should have ng-invalid class when required field is empty', () => { - const hostElement = spectator.queryHost('dot-edit-content-binary-field'); - expect(hostElement).toHaveClass('ng-invalid'); - }); - - it('should show validation border when form is marked as touched with empty required field', () => { - spectator.hostComponent.form.markAllAsTouched(); - spectator.detectChanges(); - - const hostElement = spectator.queryHost('dot-edit-content-binary-field'); - expect(hostElement).toHaveClass('ng-invalid'); - expect(hostElement).toHaveClass('ng-touched'); - }); - - it('should remove ng-invalid class when a file is selected', () => { - spectator.component.setTempFile(TEMP_FILE_MOCK); - spectator.detectChanges(); - - const hostElement = spectator.queryHost('dot-edit-content-binary-field'); - expect(hostElement).not.toHaveClass('ng-invalid'); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.stories.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.stories.ts deleted file mode 100644 index 5d844fd01397..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.stories.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; -import { moduleMetadata, StoryObj, Meta, applicationConfig } from '@storybook/angular'; -import { of } from 'rxjs'; - -import { CommonModule } from '@angular/common'; -import { provideHttpClient } from '@angular/common/http'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; -import { InputTextModule } from 'primeng/inputtext'; - -import { DotLicenseService, DotMessageService, DotUploadService } from '@dotcms/data-access'; -import { - DotTempFileThumbnailComponent, - DotDropZoneComponent, - DotFieldValidationMessageComponent, - DotMessagePipe, - DotSpinnerModule -} from '@dotcms/ui'; - -import { DotBinaryFieldPreviewComponent } from './components/dot-binary-field-preview/dot-binary-field-preview.component'; -import { DotBinaryFieldUiMessageComponent } from './components/dot-binary-field-ui-message/dot-binary-field-ui-message.component'; -import { DotBinaryFieldUrlModeComponent } from './components/dot-binary-field-url-mode/dot-binary-field-url-mode.component'; -import { DotEditContentBinaryFieldComponent } from './dot-edit-content-binary-field.component'; -import { DotBinaryFieldStore } from './store/binary-field.store'; -import { CONTENTLET, CONTENTTYPE_FIELDS_MESSAGE_MOCK, TEMP_FILES_MOCK } from './utils/mock'; - -import { BINARY_FIELD_MOCK } from '../../utils/mocks'; - -const meta: Meta = { - title: 'Library / Edit Content / Binary Field', - component: DotEditContentBinaryFieldComponent, - decorators: [ - applicationConfig({ - providers: [provideHttpClient(), DotMessageService] - }), - moduleMetadata({ - imports: [ - BrowserAnimationsModule, - CommonModule, - ButtonModule, - DialogModule, - MonacoEditorModule, - DotDropZoneComponent, - DotBinaryFieldUiMessageComponent, - DotMessagePipe, - DotSpinnerModule, - InputTextModule, - DotBinaryFieldUrlModeComponent, - DotBinaryFieldPreviewComponent, - DotFieldValidationMessageComponent, - DotTempFileThumbnailComponent - ], - providers: [ - DotBinaryFieldStore, - { - provide: DotLicenseService, - useValue: { - isEnterprise: () => of(true) - } - }, - { - provide: DotUploadService, - useValue: { - uploadFile: () => { - return new Promise((resolve, _reject) => { - setTimeout(() => { - const index = Math.floor(Math.random() * 3); - const TEMP_FILE = TEMP_FILES_MOCK[index]; - resolve(TEMP_FILE); // TEMP_FILES_MOCK is imported from utils/mock.ts - }, 2000); - }); - } - } - }, - { - provide: DotMessageService, - useValue: CONTENTTYPE_FIELDS_MESSAGE_MOCK - } - ] - }) - ], - args: { - contentlet: CONTENTLET, - field: BINARY_FIELD_MOCK - }, - argTypes: { - contentlet: { - defaultValue: CONTENTLET, - control: 'object', - description: 'Contentlet Object' - }, - field: { - defaultValue: BINARY_FIELD_MOCK, - control: 'object', - description: 'Content Type Field Object' - } - }, - render: (args) => ({ - props: args, - template: `` - }) -}; -export default meta; - -type Story = StoryObj; - -export const Primary: Story = {}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts deleted file mode 100644 index 45ff5a87b787..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { MonacoEditorConstructionOptions, MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; - -import { AsyncPipe } from '@angular/common'; -import { - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - computed, - DestroyRef, - ElementRef, - EventEmitter, - forwardRef, - inject, - Input, - OnDestroy, - OnInit, - Output, - signal, - Signal, - ViewChild -} from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -import { ButtonModule } from 'primeng/button'; -import { DialogModule } from 'primeng/dialog'; -import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { InputTextModule } from 'primeng/inputtext'; -import { TooltipModule } from 'primeng/tooltip'; - -import { delay, filter, skip, tap } from 'rxjs/operators'; - -import { DotAiService, DotLicenseService, DotMessageService } from '@dotcms/data-access'; -import { - DotCMSBaseTypesContentTypes, - DotCMSContentlet, - DotCMSContentTypeField, - DotCMSContentTypeFieldVariable, - DotCMSTempFile, - DotGeneratedAIImage -} from '@dotcms/dotcms-models'; -import { - DotAIImagePromptComponent, - DotDropZoneComponent, - DotMessagePipe, - DotSpinnerComponent, - DropZoneErrorType, - DropZoneFileEvent, - DropZoneFileValidity -} from '@dotcms/ui'; - -import { DotBinaryFieldEditorComponent } from './components/dot-binary-field-editor/dot-binary-field-editor.component'; -import { DotBinaryFieldPreviewComponent } from './components/dot-binary-field-preview/dot-binary-field-preview.component'; -import { DotBinaryFieldUiMessageComponent } from './components/dot-binary-field-ui-message/dot-binary-field-ui-message.component'; -import { DotBinaryFieldUrlModeComponent } from './components/dot-binary-field-url-mode/dot-binary-field-url-mode.component'; -import { BinaryFieldMode, BinaryFieldStatus } from './interfaces'; -import { DotBinaryFieldEditImageService } from './service/dot-binary-field-edit-image/dot-binary-field-edit-image.service'; -import { DotBinaryFieldValidatorService } from './service/dot-binary-field-validator/dot-binary-field-validator.service'; -import { DotBinaryFieldStore } from './store/binary-field.store'; -import { getUiMessage } from './utils/binary-field-utils'; - -import { DEFAULT_MONACO_CONFIG } from '../../models/dot-edit-content-field.constant'; -import { getFieldVariablesParsed, stringToJson } from '../../utils/functions.util'; - -export const DEFAULT_BINARY_FIELD_MONACO_CONFIG: MonacoEditorConstructionOptions = { - ...DEFAULT_MONACO_CONFIG, - language: 'text' -}; - -type SystemOptionsType = { - allowURLImport: boolean; - allowCodeWrite: boolean; - allowGenerateImg: boolean; -}; - -@Component({ - selector: 'dot-edit-content-binary-field', - imports: [ - ButtonModule, - DialogModule, - DotDropZoneComponent, - MonacoEditorModule, - DotMessagePipe, - DotBinaryFieldUiMessageComponent, - DotSpinnerComponent, - DotBinaryFieldEditorComponent, - InputTextModule, - DotBinaryFieldUrlModeComponent, - DotBinaryFieldPreviewComponent, - TooltipModule, - AsyncPipe - ], - providers: [ - DialogService, - DotBinaryFieldEditImageService, - DotBinaryFieldStore, - DotLicenseService, - DotBinaryFieldValidatorService, - { - multi: true, - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DotEditContentBinaryFieldComponent) - } - ], - templateUrl: './dot-edit-content-binary-field.component.html', - styleUrls: ['./dot-edit-content-binary-field.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class DotEditContentBinaryFieldComponent - implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor -{ - readonly #dotBinaryFieldStore = inject(DotBinaryFieldStore); - readonly #dotMessageService = inject(DotMessageService); - readonly #dotBinaryFieldEditImageService = inject(DotBinaryFieldEditImageService); - readonly #dotBinaryFieldValidatorService = inject(DotBinaryFieldValidatorService); - readonly #cd = inject(ChangeDetectorRef); - readonly #dotAiService = inject(DotAiService); - readonly #dialogService = inject(DialogService); - readonly #destroyRef = inject(DestroyRef); - - $isAIPluginInstalled = toSignal(this.#dotAiService.checkPluginInstallation(), { - initialValue: false - }); - $tooltipTextAIBtn = computed(() => { - const isAIPluginInstalled = this.$isAIPluginInstalled(); - if (!isAIPluginInstalled) { - return this.#dotMessageService.get('dot.binary.field.action.generate.with.tooltip'); - } - - return null; - }); - - value: string | null = null; - - @Input({ required: true }) - set field(contentTypeField: DotCMSContentTypeField) { - this.$field.set(contentTypeField); - } - @Input({ required: true }) contentlet: DotCMSContentlet; - @Input() imageEditor = false; - - $field = signal({} as DotCMSContentTypeField); - $variable = computed(() => this.$field()?.variable); - $disabled = signal(false); - - @Output() valueUpdated = new EventEmitter<{ value: string; fileName: string }>(); - @ViewChild('inputFile') inputFile: ElementRef; - readonly dialogFullScreenStyles = { height: '90%', width: '90%' }; - readonly dialogHeaderMap = { - [BinaryFieldMode.URL]: 'dot.binary.field.dialog.import.from.url.header', - [BinaryFieldMode.EDITOR]: 'dot.binary.field.dialog.create.new.file.header' - }; - readonly BinaryFieldStatus = BinaryFieldStatus; - readonly BinaryFieldMode = BinaryFieldMode; - readonly vm$ = this.#dotBinaryFieldStore.vm$; - #dialogRef: DynamicDialogRef | null = null; - dialogOpen = false; - customMonacoOptions: Signal = computed(() => { - const field = this.$field(); - - return { - ...this.parseCustomMonacoOptions(field?.fieldVariables) - }; - }); - private onChange: (value: string) => void; - private onTouched: () => void; - private tempId = ''; - - systemOptions = signal({ - allowURLImport: false, - allowCodeWrite: false, - allowGenerateImg: false - }); - - constructor() { - this.#dotMessageService.init(); - } - - get maxFileSize(): number { - return this.#dotBinaryFieldValidatorService.maxFileSize; - } - - get accept(): string[] { - return this.#dotBinaryFieldValidatorService.accept; - } - - get variable() { - return this.$variable(); - } - - ngOnInit() { - this.#dotBinaryFieldStore.value$ - .pipe( - skip(1), - filter(({ value }) => value !== this.getValue()) - ) - .subscribe(({ value, fileName }) => { - this.tempId = value; // If the value changes, it means that a new file was uploaded - this.valueUpdated.emit({ value, fileName }); - - if (this.onChange) { - this.onChange(value); - this.onTouched(); - } - }); - - this.#dotBinaryFieldEditImageService - .editedImage() - .pipe( - filter((tempFile) => !!tempFile), - tap(() => this.#dotBinaryFieldStore.setStatus(BinaryFieldStatus.UPLOADING)), - delay(500) // Loading animation - ) - .subscribe((temp) => this.#dotBinaryFieldStore.setFileFromTemp(temp)); - - this.#dotBinaryFieldStore.setMaxFileSize(this.maxFileSize); - } - - ngAfterViewInit() { - this.setFieldVariables(); - - if (!this.contentlet || !this.getValue() || !this.checkMetadata()) { - return; - } - - this.#dotBinaryFieldStore.setFileFromContentlet({ - ...this.contentlet, - value: this.getValue(), - fieldVariable: this.variable - }); - - this.#cd.detectChanges(); - } - - writeValue(value: string): void { - this.value = value; - this.#dotBinaryFieldStore.setValue(value); - } - - registerOnChange(fn: (value: string) => void) { - this.onChange = fn; - } - - registerOnTouched(fn: () => void) { - this.onTouched = fn; - } - - setDisabledState(isDisabled: boolean): void { - this.$disabled.set(isDisabled); - } - - ngOnDestroy() { - this.#dotBinaryFieldEditImageService.removeListener(); - this.#dialogRef?.close(); - } - - /** - * Open dialog to create new file or import from url - * - * @param {BinaryFieldMode} mode - * @memberof DotEditContentBinaryFieldComponent - */ - openDialog(mode: BinaryFieldMode) { - if (this.$disabled()) { - return; - } - - if (mode === BinaryFieldMode.AI) { - this.openAIImagePrompt(); - } else { - this.dialogOpen = true; - } - - this.#dotBinaryFieldStore.setMode(mode); - } - - /** - * Opens a dialog for AI Image Prompt using the DotAIImagePromptComponent. - * The dialog has various configurations such as header, appendTo, closeOnEscape, draggable, - * keepInViewport, maskStyleClass, resizable, modal, width, and style. - * - * When the dialog is closed, it filters the selected image and if an image is selected, - * it parses the image to a temporary file and sets it in the dotBinaryFieldStore. - * - * @private - */ - openAIImagePrompt() { - const header = this.#dotMessageService.get('dot.binary.field.action.generate.dialog-title'); - - this.#dialogRef = this.#dialogService.open(DotAIImagePromptComponent, { - header, - appendTo: 'body', - closable: true, - closeOnEscape: false, - draggable: false, - keepInViewport: false, - maskStyleClass: 'p-dialog-mask-transparent-ai', - resizable: false, - modal: true, - width: '90%', - style: { 'max-width': '1040px' } - }); - - this.#dialogRef.onClose - .pipe( - filter((selectedImage: DotGeneratedAIImage) => !!selectedImage), - takeUntilDestroyed(this.#destroyRef) - ) - .subscribe((selectedImage: DotGeneratedAIImage) => { - const tempFile = this.parseToTempFile(selectedImage); - this.#dotBinaryFieldStore.setTempFile(tempFile); - }); - } - - /** - * Close dialog - * - * @memberof DotEditContentBinaryFieldComponent - */ - closeDialog() { - this.dialogOpen = false; - this.#dialogRef?.close(); - this.#dotBinaryFieldStore.setMode(BinaryFieldMode.DROPZONE); - } - - /** - * Open file picker - * - * @memberof DotEditContentBinaryFieldComponent - */ - openFilePicker() { - if (this.$disabled()) { - return; - } - - this.inputFile.nativeElement.click(); - } - - /** - * Handle file selection - * - * @param {Event} event - * @memberof DotEditContentBinaryFieldComponent - */ - handleFileSelection(event: Event) { - if (this.$disabled()) { - return; - } - - const input = event.target as HTMLInputElement; - const file = input.files[0]; - this.#dotBinaryFieldStore.handleUploadFile(file); - } - - /** - * Remove file - * - * @memberof DotEditContentBinaryFieldComponent - */ - removeFile() { - if (this.$disabled()) { - return; - } - - this.#dotBinaryFieldStore.removeFile(); - } - - /** - * Set temp file - * - * @param {DotCMSTempFile} tempFile - * @memberof DotEditContentBinaryFieldComponent - */ - setTempFile(tempFile: DotCMSTempFile) { - this.#dotBinaryFieldStore.setFileFromTemp(tempFile); - this.dialogOpen = false; - } - - /** - * Open Dialog to edit file in editor - * - * @memberof DotEditContentBinaryFieldComponent - */ - onEditFile() { - this.openDialog(BinaryFieldMode.EDITOR); - } - - /** - * Open Image Editor - * - * @memberof DotEditContentBinaryFieldComponent - */ - onEditImage() { - this.#dotBinaryFieldEditImageService.openImageEditor({ - inode: this.contentlet?.inode, - tempId: this.tempId, - variable: this.variable - }); - } - - /** - * Set drop zone active state - * - * @param {boolean} value - * @memberof DotEditContentBinaryFieldComponent - */ - setDropZoneActiveState(value: boolean) { - this.#dotBinaryFieldStore.setDropZoneActive(value); - } - - /** - * Handle file drop - * - * @param {DropZoneFileEvent} { validity, file } - * @return {*} - * @memberof DotEditContentBinaryFieldComponent - */ - handleFileDrop({ validity, file }: DropZoneFileEvent): void { - if (this.$disabled()) { - return; - } - - if (!validity.valid) { - this.handleFileDropError(validity); - - return; - } - - this.#dotBinaryFieldStore.handleUploadFile(file); - } - - /** - * Set field variables - * - * @private - * @memberof DotEditContentBinaryFieldComponent - */ - private setFieldVariables() { - const field = this.$field(); - const { - accept, - maxFileSize = 0, - systemOptions = `{ - "allowURLImport": true, - "allowCodeWrite": true, - "allowGenerateImg": true - }` - } = getFieldVariablesParsed<{ - accept: string; - maxFileSize: string; - systemOptions: string; - }>(field?.fieldVariables); - - this.#dotBinaryFieldValidatorService.setAccept(accept ? accept.split(',') : []); - this.#dotBinaryFieldValidatorService.setMaxFileSize(Number(maxFileSize)); - this.systemOptions.set(JSON.parse(systemOptions)); - this.#cd.detectChanges(); - } - - /** - * Handle file drop error - * - * @private - * @param {DropZoneFileValidity} { errorsType } - * @memberof DotEditContentBinaryFieldComponent - */ - private handleFileDropError({ errorsType }: DropZoneFileValidity): void { - const messageArgs = { - [DropZoneErrorType.FILE_TYPE_MISMATCH]: this.accept.join(', '), - [DropZoneErrorType.MAX_FILE_SIZE_EXCEEDED]: `${this.maxFileSize} bytes` - }; - const errorType = errorsType[0]; - const uiMessage = getUiMessage(errorType, messageArgs[errorType]); - - this.#dotBinaryFieldStore.invalidFile(uiMessage); - } - - /** - * Parses the custom Monaco options for a given field of a DotCMSContentTypeField. - * - * @returns {Record} Returns the parsed custom Monaco options as a key-value pair object. - * @private - * @param fieldVariables - */ - private parseCustomMonacoOptions( - fieldVariables: DotCMSContentTypeFieldVariable[] - ): Record { - const { monacoOptions } = getFieldVariablesParsed<{ monacoOptions: string }>( - fieldVariables - ); - - return stringToJson(monacoOptions); - } - - /** - * Check if the contentlet has metadata - * - * @private - * @return {*} {boolean} - * @memberof DotEditContentBinaryFieldComponent - */ - private checkMetadata(): boolean { - const { baseType } = this.contentlet; - const isFileAsset = baseType === DotCMSBaseTypesContentTypes.FILEASSET; - const key = isFileAsset ? 'metaData' : this.variable + 'MetaData'; - - return !!this.contentlet[key]; - } - - private parseToTempFile(selectedImage: DotGeneratedAIImage) { - const { response } = selectedImage; - const { contentlet } = response; - const metaData = contentlet['assetMetaData']; - - const tempFile: DotCMSTempFile = { - id: response.response, - fileName: response.tempFileName, - folder: contentlet.folder, - image: true, - length: metaData.length, - mimeType: metaData.contentType, - referenceUrl: contentlet.asset, - thumbnailUrl: contentlet.asset, - metadata: metaData - }; - - return tempFile; - } - - private getValue() { - if (this.value !== null) { - return this.value; - } - - return this.contentlet?.[this.variable] ?? this.$field().defaultValue; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/interfaces/index.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/interfaces/index.ts deleted file mode 100644 index 09ac28d753ea..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/interfaces/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { DotFileMetadata } from '@dotcms/dotcms-models'; -import { DropZoneErrorType } from '@dotcms/ui'; - -export enum BinaryFieldMode { - DROPZONE = 'DROPZONE', - URL = 'URL', - EDITOR = 'EDITOR', - AI = 'AI' -} - -export enum BinaryFieldStatus { - INIT = 'INIT', - UPLOADING = 'UPLOADING', - PREVIEW = 'PREVIEW' -} - -export interface DotFilePreview extends DotFileMetadata { - id: string; - titleImage: string; - inode?: string; - url?: string; - content?: string; -} - -export enum UI_MESSAGE_KEYS { - DEFAULT = 'DEFAULT', - SERVER_ERROR = 'SERVER_ERROR' -} - -type BINARY_FIELD_MESSAGE_KEY = UI_MESSAGE_KEYS | DropZoneErrorType; - -export type UiMessageMap = { - [key in BINARY_FIELD_MESSAGE_KEY]: UiMessageI; -}; - -export interface UiMessageI { - message: string; - severity: string; - icon: string; - args?: string[]; -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.spec.ts deleted file mode 100644 index b434def8b22e..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect } from '@jest/globals'; -import { SpectatorService, createServiceFactory } from '@ngneat/spectator'; - -import { skip } from 'rxjs/operators'; - -import { DotBinaryFieldEditImageService } from './dot-binary-field-edit-image.service'; - -describe('DotBinaryFieldEditImageService', () => { - let spectator: SpectatorService; - - const createService = createServiceFactory({ - service: DotBinaryFieldEditImageService - }); - - let spyDispatchEvent: jest.SpyInstance; - let spyAddEventListener: jest.SpyInstance; - let spyRemoveEventListener: jest.SpyInstance; - - beforeEach(() => { - spyDispatchEvent = jest.spyOn(document, 'dispatchEvent'); - spyAddEventListener = jest.spyOn(document, 'addEventListener'); - spyRemoveEventListener = jest.spyOn(document, 'removeEventListener'); - spectator = createService(); - }); - - it('should listen to edited image', () => { - const detail = { - variable: 'test', - inode: '456', - tempId: '789' - }; - - const tempEventName = `binaryField-tempfile-${detail.variable}`; - const openEditorEventName = `binaryField-open-image-editor-${detail.variable}`; - const openImageCustomEvent = new CustomEvent(openEditorEventName, { detail }); - - spectator.service.openImageEditor(detail); - expect(spyDispatchEvent).toHaveBeenCalledWith(openImageCustomEvent); - expect(spyAddEventListener).toHaveBeenCalledWith(tempEventName, expect.any(Function)); - }); - it('should listen to edited image 2', () => { - const detail = { - variable: 'test', - inode: '456', - tempId: '789' - }; - - const tempEventName = `binaryField-tempfile-${detail.variable}`; - const openEditorEventName = `binaryField-open-image-editor-${detail.variable}`; - const openImageCustomEvent = new CustomEvent(openEditorEventName, { detail }); - - spectator.service.openImageEditor(detail); - expect(spyDispatchEvent).toHaveBeenCalledWith(openImageCustomEvent); - expect(spyAddEventListener).toHaveBeenCalledWith(tempEventName, expect.any(Function)); - }); - - it('should listen to edited image 3', () => { - const detail = { - variable: 'test', - inode: '456', - tempId: '789' - }; - - const tempEventName = `binaryField-tempfile-${detail.variable}`; - const openEditorEventName = `binaryField-open-image-editor-${detail.variable}`; - const openImageCustomEvent = new CustomEvent(openEditorEventName, { detail }); - - spectator.service.openImageEditor(detail); - expect(spyDispatchEvent).toHaveBeenCalledWith(openImageCustomEvent); - expect(spyAddEventListener).toHaveBeenCalledWith(tempEventName, expect.any(Function)); - }); - - it('should emit edited image and remove listener', (done) => { - const tempFile = { id: '123', url: 'http://example.com/image.jpg' }; - const data = { - variable: 'test', - inode: '456', - tempId: '789' - }; - - spectator.service - .editedImage() - .pipe(skip(1)) - .subscribe((file) => { - expect(file).toEqual(tempFile); - done(); - }); - - const tempEventName = `binaryField-tempfile-${data.variable}`; - const closeEventName = `binaryField-close-image-editor-${data.variable}`; - - spectator.service.openImageEditor(data); - document.dispatchEvent(new CustomEvent(tempEventName, { detail: { tempFile } })); - - expect(spyRemoveEventListener.mock.calls[0]).toEqual([tempEventName, expect.any(Function)]); - expect(spyRemoveEventListener.mock.calls[1]).toEqual([ - closeEventName, - expect.any(Function) - ]); - }); - - it('should listen to close image editor and remove listeners', () => { - const data = { - variable: 'test', - inode: '456', - tempId: '789' - }; - - const tempEventName = `binaryField-tempfile-${data.variable}`; - const closeEventName = `binaryField-close-image-editor-${data.variable}`; - - spectator.service.openImageEditor(data); - - document.dispatchEvent(new CustomEvent(closeEventName, {})); - expect(spyRemoveEventListener.mock.calls[0]).toEqual([tempEventName, expect.any(Function)]); - expect(spyRemoveEventListener.mock.calls[1]).toEqual([ - closeEventName, - expect.any(Function) - ]); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.ts deleted file mode 100644 index e590d1ad3d1a..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-edit-image/dot-binary-field-edit-image.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { BehaviorSubject, Observable } from 'rxjs'; - -import { Injectable } from '@angular/core'; - -import { DotCMSTempFile } from '@dotcms/dotcms-models'; - -interface ImageEditorProps { - inode: string; - tempId: string; - variable: string; -} - -@Injectable() -export class DotBinaryFieldEditImageService { - private subject: BehaviorSubject = new BehaviorSubject(null); - private variable: string; - - editedImage(): Observable { - return this.subject.asObservable(); - } - - /** - * Open the dojo image editor modal and listen to the edited image - * - * @param {ImageEditorProps} { inode, tempId, variable } - * @memberof DotBinaryFieldEditImageService - */ - openImageEditor({ inode, tempId, variable }: ImageEditorProps): void { - this.variable = variable; - const customEvent = new CustomEvent(`binaryField-open-image-editor-${variable}`, { - detail: { - inode, - tempId, - variable - } - }); - document.dispatchEvent(customEvent); - this.listenToEditedImage(); - this.listenToCloseImageEditor(); - } - - /** - * Listen to the edited image - * - * @memberof DotBinaryFieldEditImageService - */ - listenToEditedImage(): void { - document.addEventListener( - `binaryField-tempfile-${this.variable}`, - this.handleNewImage.bind(this) - ); - } - - /** - * Listen to the close image editor event - * - * @memberof DotBinaryFieldEditImageService - */ - listenToCloseImageEditor(): void { - document.addEventListener( - `binaryField-close-image-editor-${this.variable}`, - this.removeListener.bind(this) - ); - } - - /** - * Remove the listener to the edited image - * - * @memberof DotBinaryFieldEditImageService - */ - removeListener(): void { - document.removeEventListener( - `binaryField-tempfile-${this.variable}`, - this.handleNewImage.bind(this) - ); - - document.removeEventListener( - `binaryField-close-image-editor-${this.variable}`, - this.removeListener.bind(this) - ); - } - - /** - * Handle the edited image - * - * @private - * @param {*} { detail } - * @memberof DotBinaryFieldEditImageService - */ - private handleNewImage({ detail }): void { - this.subject.next(detail.tempFile); - this.removeListener(); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.spec.ts deleted file mode 100644 index 83c7fba9e82f..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; - -import { DotBinaryFieldValidatorService } from './dot-binary-field-validator.service'; - -describe('DotBinaryFieldValidatorService', () => { - let spectator: SpectatorService; - const createService = createServiceFactory(DotBinaryFieldValidatorService); - - beforeEach(() => (spectator = createService())); - - it('should validate file type', () => { - spectator.service.setAccept(['image/*', '.ts']); - expect( - spectator.service.isValidType({ extension: 'jpg', mimeType: 'image/jpeg' }) - ).toBeTruthy(); - expect( - spectator.service.isValidType({ extension: 'ts', mimeType: 'text/typescript' }) - ).toBeTruthy(); - expect( - spectator.service.isValidType({ extension: 'doc', mimeType: 'application/msword' }) - ).toBeFalsy(); - }); - - it('should validate file size', () => { - spectator.service.setMaxFileSize(5000); - expect(spectator.service.isValidSize(3000)).toBeTruthy(); - expect(spectator.service.isValidSize(6000)).toBeFalsy(); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.ts deleted file mode 100644 index e1368d726f12..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/service/dot-binary-field-validator/dot-binary-field-validator.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable() -export class DotBinaryFieldValidatorService { - #maxFileSize: number; - #accept: string[] = []; - #acceptSanitized: string[] = []; - - get accept(): string[] { - return this.#accept; - } - - get maxFileSize(): number { - return this.#maxFileSize; - } - - setMaxFileSize(maxFileSize: number) { - this.#maxFileSize = maxFileSize; - } - - setAccept(accept: string[]) { - this.#accept = accept; - this.#acceptSanitized = accept - ?.filter((value) => value !== '*/*') - .map((type) => { - // Remove the wildcard character - return type.toLowerCase().replace(/\*/g, ''); - }); - } - - isValidType({ extension, mimeType }): boolean { - if (this.#acceptSanitized?.length === 0) { - return true; - } - - const sanitizedExtension = extension?.replace('.', ''); - - return this.#acceptSanitized.some( - (type) => mimeType?.includes(type) || type?.includes(`.${sanitizedExtension}`) - ); - } - - isValidSize(size: number): boolean { - if (!this.#maxFileSize) { - return true; - } - - return size <= this.#maxFileSize; - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.spec.ts deleted file mode 100644 index e6748b8f3dbe..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.spec.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { expect, describe } from '@jest/globals'; -import { HttpMethod, SpectatorService, createServiceFactory } from '@ngneat/spectator'; -import { of } from 'rxjs'; - -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; - -import { skip } from 'rxjs/operators'; - -import { DotLicenseService, DotUploadService } from '@dotcms/data-access'; -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { DropZoneErrorType } from '@dotcms/ui'; - -import { BinaryFieldState, DotBinaryFieldStore } from './binary-field.store'; - -import { BINARY_FIELD_CONTENTLET } from '../../../utils/mocks'; -import { BinaryFieldMode, BinaryFieldStatus, UI_MESSAGE_KEYS } from '../interfaces'; -import { getUiMessage } from '../utils/binary-field-utils'; -import { fileMetaData } from '../utils/mock'; - -const INITIAL_STATE: BinaryFieldState = { - contentlet: null, - tempFile: null, - value: null, - mode: BinaryFieldMode.DROPZONE, - status: BinaryFieldStatus.INIT, - uiMessage: getUiMessage(UI_MESSAGE_KEYS.DEFAULT), - dropZoneActive: false, - isEnterprise: false -}; - -export const TEMP_FILE_MOCK: DotCMSTempFile = { - content: 'test', - fileName: 'image.png', - folder: '/images', - id: '12345', - image: true, - length: 1000, - referenceUrl: '/reference/url', - thumbnailUrl: 'image.png', - mimeType: 'mimeType', - metadata: fileMetaData -}; - -describe('DotBinaryFieldStore', () => { - let spectator: SpectatorService; - let store: DotBinaryFieldStore; - let httpMock: HttpTestingController; - - let dotUploadService: DotUploadService; - let initialState; - - const createStoreService = createServiceFactory({ - service: DotBinaryFieldStore, - imports: [HttpClientTestingModule], - providers: [ - { - provide: DotLicenseService, - useValue: { - isEnterprise: () => of(true) - } - }, - { - provide: DotUploadService, - useValue: { - uploadFile: ({ file }) => { - return new Promise((resolve) => { - if (file) { - resolve(TEMP_FILE_MOCK); - } - }); - } - } - } - ] - }); - - beforeEach(() => { - spectator = createStoreService(); - store = spectator.inject(DotBinaryFieldStore); - dotUploadService = spectator.inject(DotUploadService); - httpMock = spectator.inject(HttpTestingController); - - store.setState(INITIAL_STATE); - store.state$.subscribe((state) => { - initialState = state; - }); - }); - - it('should set initial state', () => { - expect(initialState).toEqual(INITIAL_STATE); - }); - - describe('Updaters', () => { - it('should set Contentlet', (done) => { - store.setContentlet(BINARY_FIELD_CONTENTLET); - - store.vm$.subscribe((state) => { - expect(state.contentlet).toEqual(BINARY_FIELD_CONTENTLET); - expect(state.value).toEqual(BINARY_FIELD_CONTENTLET.value); - done(); - }); - }); - - it('should set TempFile', (done) => { - store.setTempFile(TEMP_FILE_MOCK); - - store.vm$.subscribe((state) => { - expect(state.tempFile).toEqual(TEMP_FILE_MOCK); - expect(state.value).toEqual(TEMP_FILE_MOCK.id); - done(); - }); - }); - - it('should set uiMessage', (done) => { - const uiMessage = getUiMessage(DropZoneErrorType.FILE_TYPE_MISMATCH); - store.setUiMessage(uiMessage); - - store.vm$.subscribe((state) => { - expect(state.uiMessage).toEqual(uiMessage); - done(); - }); - }); - - it('should set Mode', (done) => { - store.setMode(BinaryFieldMode.EDITOR); - - store.vm$.subscribe((state) => { - expect(state.mode).toBe(BinaryFieldMode.EDITOR); - done(); - }); - }); - - it('should set Status', (done) => { - store.setStatus(BinaryFieldStatus.PREVIEW); - - store.vm$.subscribe((state) => { - expect(state.status).toBe(BinaryFieldStatus.PREVIEW); - done(); - }); - }); - - it('should set DropZoneActive', (done) => { - store.setDropZoneActive(true); - - store.vm$.subscribe((state) => { - expect(state.dropZoneActive).toBe(true); - done(); - }); - }); - }); - - describe('Actions', () => { - describe('handleUploadFile', () => { - it('should set value from tempFile and status to PREVIEW when dropping a valid', (done) => { - const file = new File([''], 'filename'); - const spyUploading = jest.spyOn(store, 'setUploading'); - - store.handleUploadFile(file); - - // Skip initial state - store.value$.pipe(skip(1)).subscribe((value) => { - expect(value).toEqual({ - value: TEMP_FILE_MOCK.id, - fileName: TEMP_FILE_MOCK.fileName - }); - done(); - }); - - expect(spyUploading).toHaveBeenCalled(); - }); - - it('should called tempFile API with 1MB', (done) => { - const file = new File([''], 'filename'); - const spyOnUploadService = jest.spyOn(dotUploadService, 'uploadFile'); - - // 1MB - store.setMaxFileSize(1048576); - store.handleUploadFile(file); - - // Skip initial state - store.value$.pipe(skip(1)).subscribe(() => { - expect(spyOnUploadService).toHaveBeenCalledWith({ - file, - maxSize: '1MB' - }); - done(); - }); - }); - }); - }); - - describe('Effects', () => { - describe('setFileFromContentlet', () => { - it(`should get content if the file is editableAsText`, (done) => { - const { BinaryMetaData } = BINARY_FIELD_CONTENTLET; - const metaData = { - ...BinaryMetaData, - editableAsText: true - }; - - const NEW_BINARY_FIELD_CONTENTLET = { - ...BINARY_FIELD_CONTENTLET, - fileAssetVersion: '12345', - metaData - }; - - store.setFileFromContentlet(NEW_BINARY_FIELD_CONTENTLET); - - const req = httpMock.expectOne('12345', HttpMethod.GET); // Need to check here - req.flush('DATA'); // Need to flush here - - store.state$.subscribe((state) => { - expect(state.contentlet).toEqual({ - ...NEW_BINARY_FIELD_CONTENTLET, - mimeType: metaData.contentType, - name: metaData.name, - content: 'DATA' - }); - done(); - }); - }); - - it('should not get content if the file is not editableAsText', (done) => { - store.setFileFromContentlet({ - ...BINARY_FIELD_CONTENTLET, - metaData: { - ...fileMetaData, - editableAsText: false - } - }); - - store.state$.subscribe(() => { - httpMock.expectNone('test-url', HttpMethod.GET); - done(); - }); - }); - }); - - describe('setFileFromTemp', () => { - it(`should get content if the file is editableAsText`, (done) => { - const NEW_TEMP_FILE_MOCK = { - ...TEMP_FILE_MOCK, - metadata: { - ...fileMetaData, - editableAsText: true - } - }; - store.setFileFromTemp(NEW_TEMP_FILE_MOCK); - - const req = httpMock.expectOne(TEMP_FILE_MOCK.referenceUrl, HttpMethod.GET); // Need to check here - req.flush('DATA'); // Need to flush here - - store.state$.subscribe((state) => { - expect(state.tempFile).toEqual({ - ...NEW_TEMP_FILE_MOCK, - content: 'DATA' - }); - done(); - }); - }); - - it('should not get content if the file is not editableAsText', (done) => { - store.setFileFromTemp({ - ...TEMP_FILE_MOCK, - metadata: fileMetaData - }); - - store.state$.subscribe(() => { - httpMock.expectNone('test-url', HttpMethod.GET); - done(); - }); - }); - }); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.ts deleted file mode 100644 index ad9ca2e2b145..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/store/binary-field.store.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { ComponentStore } from '@ngrx/component-store'; -import { tapResponse } from '@ngrx/operators'; -import { from, Observable, of } from 'rxjs'; - -import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; - -import { switchMap, tap, map, catchError, distinctUntilChanged } from 'rxjs/operators'; - -import { DotLicenseService, DotUploadService } from '@dotcms/data-access'; -import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; - -import { - BinaryFieldMode, - BinaryFieldStatus, - UI_MESSAGE_KEYS, - UiMessageI -} from '../interfaces/index'; -import { getFieldVersion, getFileMetadata, getUiMessage } from '../utils/binary-field-utils'; - -export interface BinaryFieldState { - contentlet: DotCMSContentlet; - tempFile: DotCMSTempFile; - value: string; - mode: BinaryFieldMode; - status: BinaryFieldStatus; - uiMessage: UiMessageI; - dropZoneActive: boolean; - isEnterprise: boolean; -} - -const initialState: BinaryFieldState = { - contentlet: null, - tempFile: null, - value: null, - mode: BinaryFieldMode.DROPZONE, - status: BinaryFieldStatus.INIT, - dropZoneActive: false, - uiMessage: getUiMessage(UI_MESSAGE_KEYS.DEFAULT), - isEnterprise: false -}; - -@Injectable() -export class DotBinaryFieldStore extends ComponentStore { - private readonly dotUploadService = inject(DotUploadService); - private readonly dotLicenseService = inject(DotLicenseService); - private readonly http = inject(HttpClient); - - private _maxFileSizeInMB = 0; - - get maxFile() { - return this._maxFileSizeInMB ? `${this._maxFileSizeInMB}MB` : ''; - } - - // Selectors - readonly vm$ = this.select((state) => ({ - ...state, - isLoading: state.status === BinaryFieldStatus.UPLOADING - })); - - readonly value$ = this.select(({ value, tempFile }) => ({ - value, - fileName: tempFile?.fileName - })).pipe(distinctUntilChanged((previous, current) => previous.value === current.value)); - - constructor() { - super(initialState); - this.dotLicenseService.isEnterprise().subscribe((isEnterprise) => { - this.setIsEnterprise(isEnterprise); - }); - } - - readonly setDropZoneActive = this.updater((state, dropZoneActive) => ({ - ...state, - dropZoneActive - })); - - readonly setContentlet = this.updater((state, contentlet) => ({ - ...state, - contentlet, - status: BinaryFieldStatus.PREVIEW, - value: contentlet?.value || '' - })); - - readonly setTempFile = this.updater((state, tempFile) => ({ - ...state, - tempFile, - contentlet: null, - status: BinaryFieldStatus.PREVIEW, - value: tempFile?.id - })); - - readonly setValue = this.updater((state, value) => ({ - ...state, - value - })); - - readonly setUiMessage = this.updater((state, uiMessage) => ({ - ...state, - uiMessage - })); - - readonly setMode = this.updater((state, mode) => ({ - ...state, - mode - })); - - readonly setStatus = this.updater((state, status) => ({ - ...state, - status - })); - - readonly setIsEnterprise = this.updater((state, isEnterprise) => ({ - ...state, - isEnterprise - })); - - readonly setUploading = this.updater((state) => ({ - ...state, - dropZoneActive: false, - uiMessage: getUiMessage(UI_MESSAGE_KEYS.DEFAULT), - status: BinaryFieldStatus.UPLOADING - })); - - readonly setError = this.updater((state, uiMessage) => ({ - ...state, - uiMessage, - status: BinaryFieldStatus.INIT, - tempFile: null - })); - - readonly invalidFile = this.updater((state, uiMessage) => ({ - ...state, - dropZoneActive: false, - uiMessage, - status: BinaryFieldStatus.INIT - })); - - readonly removeFile = this.updater((state) => ({ - ...state, - contentlet: null, - tempFile: null, - value: '', - status: BinaryFieldStatus.INIT - })); - - // Effects - readonly handleUploadFile = this.effect((file$: Observable) => - file$.pipe( - tap(() => this.setUploading()), - switchMap((file) => - this.uploadFile(file).pipe( - switchMap((tempFile) => this.handleTempFile(tempFile)), - tap((file) => this.setTempFile(file)), - catchError(() => { - this.setError(getUiMessage(UI_MESSAGE_KEYS.SERVER_ERROR)); - - return of(null); - }) - ) - ) - ) - ); - - readonly setFileFromTemp = this.effect((file$: Observable) => { - return file$.pipe( - tap(() => this.setUploading()), - - switchMap((tempFile) => { - return this.handleTempFile(tempFile).pipe( - tapResponse({ - next: (file) => this.setTempFile(file), - error: () => this.setError(getUiMessage(UI_MESSAGE_KEYS.SERVER_ERROR)) - }) - ); - }) - ); - }); - - readonly setFileFromContentlet = this.effect( - (contentlet$: Observable) => { - return contentlet$.pipe( - tap(() => this.setUploading()), - switchMap((contentlet) => { - const { contentType, editableAsText, name } = getFileMetadata(contentlet); - const contentURL = getFieldVersion(contentlet); - const obs$ = editableAsText ? this.getFileContent(contentURL) : of(''); - - return obs$.pipe( - tapResponse({ - next: (content = '') => { - this.setContentlet({ - ...contentlet, - mimeType: contentType, - name, - content - }); - }, - error: () => { - this.setContentlet({ - ...contentlet, - mimeType: contentType, - name - }); - } - }) - ); - }) - ); - } - ); - - private handleTempFile(tempFile: DotCMSTempFile): Observable { - const { referenceUrl, metadata } = tempFile; - const { editableAsText = false } = metadata; - - const obs$ = editableAsText ? this.getFileContent(referenceUrl) : of(''); - - return obs$.pipe( - map((content) => { - return { - ...tempFile, - content - }; - }) - ); - } - - /** - * Set the max file size in bytes - * - * @param bytes The max file size in bytes - */ - setMaxFileSize(bytes: number): void { - // Convert bytes to MB - this._maxFileSizeInMB = bytes / (1024 * 1024); - } - - private uploadFile(file: File): Observable { - return from( - this.dotUploadService.uploadFile({ - file, - maxSize: this.maxFile - }) - ); - } - - /** - * Get the file content - * - * @private - * @param {string} url - * @return {*} {Observable} - * @memberof DotBinaryFieldStore - */ - private getFileContent(url: string): Observable { - return this.http.get(url, { responseType: 'text' }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/binary-field-utils.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/binary-field-utils.ts deleted file mode 100644 index bb216eb7eac1..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/binary-field-utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { DotCMSContentlet } from '@dotcms/dotcms-models'; - -import { UiMessageI, UiMessageMap } from '../interfaces'; - -const UiMessageMap: UiMessageMap = { - DEFAULT: { - message: 'dot.binary.field.drag.and.drop.message', - severity: 'info', - icon: 'pi pi-upload' - }, - SERVER_ERROR: { - message: 'dot.binary.field.drag.and.drop.error.server.error.message', - severity: 'error', - icon: 'pi pi-exclamation-triangle' - }, - FILE_TYPE_MISMATCH: { - message: 'dot.binary.field.drag.and.drop.error.file.not.supported.message', - severity: 'error', - icon: 'pi pi-exclamation-triangle' - }, - MAX_FILE_SIZE_EXCEEDED: { - message: 'dot.binary.field.drag.and.drop.error.file.maxsize.exceeded.message', - severity: 'error', - icon: 'pi pi-exclamation-triangle' - }, - MULTIPLE_FILES_DROPPED: { - message: 'dot.binary.field.drag.and.drop.error.multiple.files.dropped.message', - severity: 'error', - icon: 'pi pi-exclamation-triangle' - } -}; - -export const getUiMessage = (messageKey: string, ...args: string[]): UiMessageI => { - return { - ...UiMessageMap[messageKey], - args - }; -}; - -export const getFileMetadata = (contentlet: DotCMSContentlet) => { - const { metaData, fieldVariable } = contentlet; - - const metadata = metaData || contentlet[`${fieldVariable}MetaData`]; - - return metadata || {}; -}; - -export const getFieldVersion = (contentlet: DotCMSContentlet) => { - const { fileAssetVersion, fieldVariable } = contentlet; - - return fileAssetVersion || contentlet[`${fieldVariable}Version`]; -}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/mock.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/mock.ts deleted file mode 100644 index 1f4ef9a5af7b..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-binary-field/utils/mock.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { DotCMSTempFile } from '@dotcms/dotcms-models'; -import { MockDotMessageService } from '@dotcms/utils-testing'; - -const MESSAGES_MOCK = { - 'dot.binary.field.action.choose.file': 'Choose File', - 'dot.binary.field.action.create.new.file': 'Create New File', - 'dot.binary.field.action.create.new.file.label': 'File Name', - 'dot.binary.field.action.import.from.url.error.message': - 'The URL you requested is not valid. Please try again.', - 'dot.binary.field.action.import.from.url': 'Import from URL', - 'dot.binary.field.action.remove': 'Remove', - 'dot.binary.field.dialog.create.new.file.header': 'File Details', - 'dot.binary.field.dialog.import.from.url.header': 'URL', - 'dot.binary.field.drag.and.drop.error.could.not.load.message': - 'Couldn't load the file. Please try again or', - 'dot.binary.field.drag.and.drop.error.file.maxsize.exceeded.message': - 'The file weight exceeds the limits of {0}, please reduce size before uploading.', - 'dot.binary.field.drag.and.drop.error.file.not.supported.message': - 'This type of file is not supported, Please select a {0} file.', - 'dot.binary.field.drag.and.drop.error.multiple.files.dropped.message': - 'You can only upload one file at a time.', - 'dot.binary.field.drag.and.drop.error.server.error.message': - 'Something went wrong, please try again or contact our support team.', - 'dot.binary.field.drag.and.drop.message': 'Drag and Drop or', - 'dot.binary.field.error.type.file.not.extension': "Please add the file's extension", - 'dot.binary.field.error.type.file.not.supported.message': - 'This type of file is not supported. Please use a {0} file.', - 'dot.binary.field.file.bytes': 'Bytes', - 'dot.binary.field.file.dimension': 'Dimension', - 'dot.binary.field.file.size': 'File Size', - 'dot.binary.field.import.from.url.error.file.not.supported.message': - 'This type of file is not supported, Please import a {0} file.', - 'dot.common.cancel': 'Cancel', - 'dot.common.edit': 'Edit', - 'dot.common.import': 'Import', - 'dot.common.remove': 'Remove', - 'dot.common.save': 'Save', - 'error.form.validator.required': 'This field is required' -}; - -export const CONTENTTYPE_FIELDS_MESSAGE_MOCK = new MockDotMessageService(MESSAGES_MOCK); - -const TEMP_IMAGE_MOCK: DotCMSTempFile = { - fileName: 'Image.jpg', - folder: 'folder', - id: 'tempFileId', - image: true, - length: 10000, - mimeType: 'image/jpeg', - referenceUrl: '', - thumbnailUrl: - 'https://images.unsplash.com/photo-1575936123452-b67c3203c357?auto=format&fit=crop&q=80&w=1000&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aW1hZ2V8ZW58MHx8MHx8fDA%3D', - metadata: { - contentType: 'image/jpeg', - fileSize: 12312, - length: 12312, - isImage: true, - modDate: 12312, - name: 'image.png', - sha256: '12345', - title: 'Asset', - version: 1, - height: 100, - width: 100, - editableAsText: false - } -}; - -const TEMP_VIDEO_MOCK = { - fileName: 'video.mp4', - folder: 'folder', - id: 'tempFileId', - image: false, - length: 10000, - mimeType: 'video/mp4', - referenceUrl: 'https://www.w3schools.com/tags/movie.mp4', - thumbnailUrl: '' -}; - -const TEMP_FILE_MOCK = { - fileName: 'template.html', - folder: 'folder', - id: 'tempFileId', - image: false, - length: 10000, - mimeType: 'text/html', - referenceUrl: 'https://raw.githubusercontent.com/angular/angular/main/README.md', - thumbnailUrl: '', - content: 'HOLA' -}; - -export const TEMP_FILES_MOCK = [TEMP_IMAGE_MOCK, TEMP_VIDEO_MOCK, TEMP_FILE_MOCK]; - -export const CONTENTLET = { - publishDate: '2023-10-24 13:21:49.682', - inode: 'b22aa2f3-12af-4ea8-9d7d-164f98ea30b1', - binaryField2: '/dA/af9294c29906dea7f4a58d845f569219/binaryField2/New-Image.png', - host: '48190c8c-42c4-46af-8d1a-0cd5db894797', - binaryField2Version: '/dA/b22aa2f3-12af-4ea8-9d7d-164f98ea30b1/binaryField2/New-Image.png', - locked: false, - stInode: 'd1901a41d38b6686dd5ed8f910346d7a', - contentType: 'BinaryField', - identifier: 'af9294c29906dea7f4a58d845f569219', - folder: 'SYSTEM_FOLDER', - hasTitleImage: true, - sortOrder: 0, - binaryField2MetaData: { - modDate: 1698153707197, - sha256: 'e84030fe91978e483e34242f0631a81903cf53a945475d8dcfbb72da484a28d5', - length: 29848, - title: 'New-Image.png', - version: 20220201, - isImage: true, - fileSize: 29848, - name: 'New-Image.png', - width: 738, - contentType: 'image/png', - height: 435 - }, - hostName: 'demo.dotcms.com', - modDate: '2023-10-24 13:21:49.682', - title: 'af9294c29906dea7f4a58d845f569219', - baseType: 'CONTENT', - archived: false, - working: true, - live: true, - owner: 'dotcms.org.1', - binaryField2ContentAsset: 'af9294c29906dea7f4a58d845f569219/binaryField2', - languageId: 1, - url: '/content.b22aa2f3-12af-4ea8-9d7d-164f98ea30b1', - titleImage: 'binaryField2', - modUserName: 'Admin User', - hasLiveVersion: true, - modUser: 'dotcms.org.1', - __icon__: 'contentIcon', - contentTypeIcon: 'event_note', - variant: 'DEFAULT' -}; - -export const fileMetaData = { - contentType: 'image/png', - fileSize: 12312, - length: 12312, - modDate: 12312, - name: 'image.png', - sha256: '12345', - title: 'Asset', - version: 1, - height: 100, - width: 100, - editableAsText: false, - isImage: true -}; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-binary-field-ce-bridge/dot-binary-field-ce-bridge.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-binary-field-ce-bridge/dot-binary-field-ce-bridge.component.ts new file mode 100644 index 000000000000..1ef9a83da16a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-binary-field-ce-bridge/dot-binary-field-ce-bridge.component.ts @@ -0,0 +1,69 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + signal +} from '@angular/core'; + +import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; + +import { INPUT_TYPES } from '../../../../models/dot-edit-content-file.model'; +import { DotFileFieldComponent } from '../dot-file-field/dot-file-field.component'; + +/** + * Custom-element bridge for the legacy `dotcms-binary-field` web component. + * + * Exposes the exact imperative contract the legacy JSP editor depends on + * (`field`, `contentlet`, `imageEditor` set as DOM properties and a + * `valueUpdated` CustomEvent) using classic `@Input()`/`@Output()` decorators. + * + * Signal `input()` does not hydrate from imperative DOM property assignment when + * a component is registered with `@angular/elements` (spike #36055), so this thin + * bridge keeps the classic API at the boundary while the unified + * {@link DotFileFieldComponent} stays signal-based internally. + * + * Image editing uses Dojo DOM events via `useLegacyDojoImageEditor` on the inner + * file field (the JSP listener in `edit_field.jsp` opens the editor). + */ +@Component({ + selector: 'dot-binary-field-ce-bridge', + template: ` + + `, + imports: [DotFileFieldComponent], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotBinaryFieldCeBridgeComponent { + /** Content type field definition (set imperatively by the legacy JSP). */ + @Input({ required: true }) + set field(value: DotCMSContentTypeField) { + this.$field.set({ ...value, fieldType: INPUT_TYPES.Binary }); + } + + /** Current contentlet (set imperatively by the legacy JSP). */ + @Input({ required: true }) + set contentlet(value: DotCMSContentlet) { + this.$contentlet.set(value); + } + + /** Whether the image editor is enabled (preserved for contract parity). */ + @Input() + set imageEditor(value: boolean) { + this.$imageEditor.set(value); + } + + /** Emitted as a DOM `valueUpdated` CustomEvent for the legacy editor. */ + @Output() valueUpdated = new EventEmitter<{ value: string; fileName: string }>(); + + protected readonly $field = signal({} as DotCMSContentTypeField); + protected readonly $contentlet = signal({} as DotCMSContentlet); + protected readonly $imageEditor = signal(true); +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html index e5b54da65582..7189b2a0e4f8 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.html @@ -1,13 +1,20 @@ @let fileInfo = $fileInfo(); @let metadata = fileInfo.metadata; -
+
@if (metadata?.editableAsText) { -
- {{ fileInfo.content }} +
+ + {{ fileInfo.content }} +
} @else { -
+
@if (fileInfo.source === 'temp') { } @else { }
-