From 5ef8895786059d52fe96843ef04d8d3e935838c1 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 29 Jun 2026 12:13:22 -0300 Subject: [PATCH 1/2] fix(content-drive): consume upload file before resetting the input The Upload-button flow silently dropped the file in Chrome: onFileChange read input.files (a live FileList) but reset input.value='' BEFORE the guard, which empties that same live FileList, so files.length was 0 and the upload no-oped. Consume the files (capture the File into FormData via resolveFilesUpload) before resetting the input. Adds a regression test (verified red on the old ordering, green on the fix) that models the live-FileList behavior jsdom doesn't reproduce. Co-Authored-By: Claude Opus 4.8 --- .../dot-content-drive-shell.component.spec.ts | 33 +++++++++++++++++++ .../dot-content-drive-shell.component.ts | 15 +++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 379035d567d5..e21ada530d1e 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -956,6 +956,39 @@ describe('DotContentDriveShellComponent', () => { }); }); + it('should consume the file before resetting the input (live FileList)', () => { + // Regression: `input.files` is a LIVE FileList, so clearing `input.value` empties it. + // The component must consume the files BEFORE resetting the input; resetting first + // drops the selection and the upload silently no-ops (the real Chrome bug). + // jsdom doesn't model this, so we mock it faithfully: `.files` is one stable object + // that is emptied when `.value` is cleared. + uploadService.uploadFileByBaseType.mockReturnValue(of({} as DotCMSContentlet)); + const file = createFile(); + const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; + + const liveFiles: File[] = [file]; + Object.defineProperty(fileInput, 'files', { + get: () => liveFiles as unknown as FileList, + configurable: true + }); + Object.defineProperty(fileInput, 'value', { + get: () => (liveFiles.length ? 'C:\\fakepath\\test.png' : ''), + set: () => { + liveFiles.length = 0; // clearing the input empties the live FileList + }, + configurable: true + }); + + selectUploadType({ targetFolder: TARGET_FOLDER_DATA, baseType: 'FILEASSET' }); + spectator.triggerEventHandler('input[type="file"]', 'change', { target: fileInput }); + + expect(uploadService.uploadFileByBaseType).toHaveBeenCalledWith(file, 'FILEASSET', { + hostFolder: TARGET_FOLDER_DATA.id, + indexPolicy: 'WAIT_FOR' + }); + expect(fileInput.value).toBe(''); // still reset afterwards + }); + it('should not upload when the file picker is dismissed without files', () => { const fileInput = spectator.query('input[type="file"]') as HTMLInputElement; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 34b8ac7cf4c8..c1b909372217 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -430,15 +430,16 @@ export class DotContentDriveShellComponent implements OnInit { const files = input.files; const selection = this.$activeSelection(); - // Always reset so a cancelled/re-opened picker can't reuse a stale selection. - this.$activeSelection.set(undefined); - input.value = ''; - - if (!files || files.length === 0 || !selection) { - return; + // Consume the files BEFORE resetting the input: `input.files` is a live FileList, so + // `input.value = ''` empties it. Resetting first would drop the selection and the upload + // would never fire (the file is captured synchronously into FormData by resolveFilesUpload). + if (files && files.length > 0 && selection) { + this.resolveFilesUpload({ ...selection, files }); } - this.resolveFilesUpload({ ...selection, files }); + // Reset so a cancelled/re-opened picker can't reuse a stale selection. + this.$activeSelection.set(undefined); + input.value = ''; } /** From 96c4f01b1418c6074956e82f300aa671e117db70 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 29 Jun 2026 13:53:11 -0300 Subject: [PATCH 2/2] feat(uve): auto-create page language version on language switch (#36348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the confirmation dialog + legacy edit-contentlet iframe with a programmatic flow: on switching to an untranslated language, fetch the page's content type, build the new-version payload from its actual field definitions, fire a workflow NEW action to create the version in the target language, show a success toast, and reload via pageLoad. Both triggers (toolbar onLanguageSelected and the editor $translatePageEffect) route through the same path. Spike #36348. Does NOT handle URL-content-map detail pages — see #36355. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dot-uve-toolbar.component.ts | 37 +----- .../edit-ema-editor.component.ts | 114 ++++++++++++++---- .../WEB-INF/messages/Language.properties | 1 + 3 files changed, 94 insertions(+), 58 deletions(-) diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts index 164c4454b1c2..c5f450940203 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/dot-uve-toolbar.component.ts @@ -298,10 +298,10 @@ export class DotUveToolbarComponent { const languageHasTranslation = currentLanguage.translated; if (!languageHasTranslation) { - // Show confirmation dialog to create a new translation + // Emit to the shell, which creates the language version and reloads. const page = this.#store.pageAsset()?.page; if (page) { - this.createNewTranslation(currentLanguage, page); + this.translatePage.emit({ page, newLanguage: currentLanguage.id }); } return; @@ -396,39 +396,6 @@ export class DotUveToolbarComponent { }); } - /* - * Asks the user for confirmation to create a new translation for a given language. - * - * @param {DotLanguage} language - The language to create a new translation for. - * @private - * - * @return {void} - */ - private createNewTranslation(language: DotLanguage, page: DotCMSPage): void { - this.#confirmationService.confirm({ - header: this.#dotMessageService.get( - 'editpage.language-change-missing-lang-populate.confirm.header' - ), - message: this.#dotMessageService.get( - 'editpage.language-change-missing-lang-populate.confirm.message', - language.language - ), - rejectIcon: 'hidden', - acceptIcon: 'hidden', - key: 'shell-confirm-dialog', - accept: () => { - this.translatePage.emit({ - page: page, - newLanguage: language.id - }); - }, - reject: () => { - // If is rejected, bring back the current language on selector - this.$languageSelector()?.value.set(this.#store.pageLanguage()); - } - }); - } - /** * Gets the minimum allowed date for the calendar component. * Sets hours/minutes/seconds/milliseconds to 0 to avoid collisions with preview date diff --git a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts index ea3aeba2aebe..a6d13e69b645 100644 --- a/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts +++ b/core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/edit-ema-editor.component.ts @@ -51,7 +51,6 @@ import { DotCMSContentType, DotCMSContentlet, DotCMSTempFile, - DotLanguage, DotTreeNode, FeaturedFlags, SeoMetaTags, @@ -421,7 +420,7 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit { const { page, currentLanguage } = this.uveStore.pageTranslateProps(); if (currentLanguage && !currentLanguage?.translated) { - this.createNewTranslation(currentLanguage, page); + this.translatePage({ page, newLanguage: currentLanguage.id }); } }); @@ -1688,30 +1687,99 @@ export class EditEmaEditorComponent implements OnDestroy, AfterViewInit { }); } - private createNewTranslation(language: DotLanguage, page: DotCMSPage): void { - this.confirmationService.confirm({ - header: this.dotMessageService.get( - 'editpage.language-change-missing-lang-populate.confirm.header' - ), - message: this.dotMessageService.get( - 'editpage.language-change-missing-lang-populate.confirm.message', - language.language - ), - rejectIcon: 'hidden', - acceptIcon: 'hidden', - key: 'shell-confirm-dialog', - accept: () => { - this.translatePage({ page, newLanguage: language.id }); - }, - reject: () => { - // If is rejected, bring back the current language on selector - this.#goBackToCurrentLanguage(); + /** + * Create the page's language version on switch. + * + * Fires a programmatic workflow NEW action that copies the source page's field + * values into a new version in the target language, then re-renders via `pageLoad`. + */ + translatePage(event: { page: DotCMSPage; newLanguage: number }) { + this.#autoCreateTranslation(event.page, event.newLanguage); + } + + #autoCreateTranslation(page: DotCMSPage, newLanguage: number): void { + // Resolve the real content type so the payload is built from its actual + // field definitions instead of guessing which page properties to copy. + this.dotContentTypeService + .getContentType(page.contentType) + .pipe( + switchMap((contentType) => { + const data = this.#buildTranslationData(contentType, page, newLanguage); + + return this.dotWorkflowActionsFireService.newContentlet( + page.contentType, + data + ); + }), + take(1), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: () => { + const languageName = + this.uveStore.pageLanguages().find((lang) => lang.id === newLanguage) + ?.language ?? ''; + + this.messageService.add({ + severity: 'success', + summary: this.dotMessageService.get('Workflow-Action'), + detail: this.dotMessageService.get( + 'editpage.language-change-missing-lang-populate.success', + languageName + ), + life: 3000 + }); + + this.uveStore.pageLoad({ language_id: newLanguage.toString() }); + }, + error: (error: HttpErrorResponse) => { + this.dotHttpErrorManagerService.handle(error); + // On failure, return to the language we came from. + this.#goBackToCurrentLanguage(); + } + }); + } + + /** + * Build the new-version payload from the content type's actual field definitions. + * + * Copies each defined field's value from the source page, preserves `identifier` + * (so it is a new language version of the same content, not a new one) and + * overrides `languageId`. Layout/divider fields carry no value on the page object, + * so they are skipped naturally. + */ + #buildTranslationData( + contentType: DotCMSContentType, + page: DotCMSPage, + newLanguage: number + ): Record { + const source = page as unknown as Record; + const data: Record = {}; + + (contentType.fields ?? []).forEach((field) => { + const key = field.variable; + + if (!key) { + return; } + + const value = source[key]; + + // Only copy primitive field values that exist on the source page. + if (value === null || value === undefined || typeof value === 'object') { + return; + } + + data[key] = String(value); }); - } - translatePage(event: { page: DotCMSPage; newLanguage: number }) { - this.dialog.translatePage(event); + data.identifier = page.identifier; + data.languageId = newLanguage.toString(); + // Wait for the new version to be indexed before pageLoad re-checks the + // `translated` flag — otherwise indexing lag could re-trigger the effect. + data.indexPolicy = 'WAIT_FOR'; + + return data; } /** diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 0e0f18270990..631e465cf8ee 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6280,6 +6280,7 @@ locale.error.message=Locale ID already exists. editpage.language-change-missing-lang-populate.confirm.header=Create New Language Version? editpage.language-change-missing-lang-populate.confirm.message=Page does not exist in the selected language. Create a new version in {0}? +editpage.language-change-missing-lang-populate.success=A new page version was created in {0}. editpage.viewing.variant=Viewing {0} Variant editpage.editing.variant=Editing {0} Variant editpage.file.upload.not.image=The file dropped was not an image.