diff --git a/core-web/apps/dotcms-ui-e2e/AGENTS.md b/core-web/apps/dotcms-ui-e2e/AGENTS.md index f8139a7bd52d..838191ccb4e7 100644 --- a/core-web/apps/dotcms-ui-e2e/AGENTS.md +++ b/core-web/apps/dotcms-ui-e2e/AGENTS.md @@ -50,7 +50,7 @@ Unsure? **Codegen first.** Avoid fragile `#dijit_*` IDs, CSS when `data-testid` - **Nav:** `Portlet` from `@utils/portlets`; new content via `NewEditContentFormPage.goToNew()` (listing → New), not direct `/content/new/` URL. - **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/`). +Field-specific selectors and flows: copy `tests/.../helpers/` and nearest `*.spec.ts` (e.g. `relationship-field/`, `key-value-field/`). ## Where to put code diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/helpers/key-value-field.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/helpers/key-value-field.ts new file mode 100644 index 000000000000..4cf6502e82ac --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/helpers/key-value-field.ts @@ -0,0 +1,90 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * Locator wrapper for the Key/Value field (`dot-edit-content-key-value`). + * Scopes interactions to `data-testid="field-{variable}"`. + */ +export class KeyValueField { + readonly root: Locator; + readonly keyInput: Locator; + readonly valueInput: Locator; + readonly saveButton: Locator; + readonly keyCells: Locator; + readonly rows: Locator; + + constructor( + private page: Page, + readonly fieldVariable = 'keyValueField' + ) { + this.root = page.getByTestId(`field-${fieldVariable}`); + this.keyInput = this.root.getByTestId('key-input'); + this.valueInput = this.root.getByTestId('value-input'); + this.saveButton = this.root.getByTestId('save-button'); + this.keyCells = this.root.getByTestId('dot-key-value-key'); + this.rows = this.root.locator('tr.dot-key-value-table-row'); + } + + async expectVisible(): Promise { + await expect(this.keyInput).toBeVisible({ timeout: 15000 }); + await expect(this.valueInput).toBeVisible(); + await expect(this.saveButton).toBeVisible(); + } + + async addEntry(key: string, value: string): Promise { + await this.keyInput.fill(key); + await this.valueInput.fill(value); + await this.saveButton.click(); + await expect(this.exactKeyCell(key)).toHaveCount(1, { timeout: 10000 }); + } + + async expectEntryCount(count: number): Promise { + await expect(this.keyCells).toHaveCount(count, { timeout: 10000 }); + } + + private static escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private exactKeyCell(key: string): Locator { + return this.keyCells.filter({ + hasText: new RegExp(`^${KeyValueField.escapeRegExp(key)}$`) + }); + } + + private valueInputForKey(key: string): Locator { + return this.exactKeyCell(key) + .locator('xpath=ancestor::tr[contains(@class,"dot-key-value-table-row")][1]') + .getByTestId('dot-key-value-input'); + } + + async expectEntry(key: string, value?: string): Promise { + const keyCell = this.exactKeyCell(key); + await expect(keyCell).toHaveCount(1, { timeout: 10000 }); + await expect(keyCell).toHaveText(key); + + if (value !== undefined) { + await expect + .poll(async () => this.valueInputForKey(key).inputValue(), { timeout: 10000 }) + .toBe(value); + } + } + + async expectKeyAbsent(key: string): Promise { + await expect(this.exactKeyCell(key)).toHaveCount(0); + } + + async editEntryValue(key: string, newValue: string): Promise { + const valueInput = this.valueInputForKey(key); + await expect(valueInput).toBeVisible({ timeout: 10000 }); + await valueInput.fill(newValue); + await valueInput.press('Enter'); + await expect.poll(async () => valueInput.inputValue(), { timeout: 10000 }).toBe(newValue); + } + + async deleteEntryByKey(key: string): Promise { + await this.exactKeyCell(key) + .locator('xpath=ancestor::tr[contains(@class,"dot-key-value-table-row")][1]') + .getByTestId('dot-key-value-delete-button') + .click(); + } +} diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/key-value-field.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/key-value-field.spec.ts new file mode 100644 index 000000000000..f1258111208d --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/edit-content/fields/key-value-field/key-value-field.spec.ts @@ -0,0 +1,241 @@ +import { faker } from '@faker-js/faker'; +import { NewEditContentFormPage } from '@pages'; +import { expect, test } from '@playwright/test'; +import { ContentType, createFakeContentType, deleteContentType } from '@requests/contentType'; +import { + createFakePayloadCustomField, + createFakePayloadKeyValueField, + createFakePayloadTextField +} from '@utils/dot-content-types.mock'; +import { uniqueSuffix } from '@utils/utils'; + +import { KeyValueField } from './helpers/key-value-field'; + +const KEY_VALUE_FIELD_VARIABLE = 'keyValueField'; +const CUSTOM_FIELD_VARIABLE = 'customField'; + +async function saveAndReload(page: import('@playwright/test').Page, title: string) { + const formPage = new NewEditContentFormPage(page); + await formPage.fillTextField(title); + await formPage.save(); + + 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 }); + + return savedContentIdentifier; +} + +test.describe('Key Value Field', () => { + test.describe('Isolated content type', () => { + let contentType: ContentType | null = null; + let contentTypeVariable: string; + + test.beforeEach(async ({ request }) => { + contentType = await createFakeContentType(request, { + name: `E2EKeyValueField${uniqueSuffix()}`, + fields: [ + createFakePayloadTextField({ + name: 'Title', + variable: 'title', + sortOrder: 1 + }), + createFakePayloadKeyValueField({ + name: 'Key Value Field', + variable: KEY_VALUE_FIELD_VARIABLE, + sortOrder: 2 + }) + ] + }); + contentTypeVariable = contentType.variable; + }); + + test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + }); + + test('first key-value entry is not split into characters @critical', async ({ page }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.expectVisible(); + await field.addEntry('mykey', 'myvalue'); + + await field.expectEntryCount(1); + await field.expectEntry('mykey', 'myvalue'); + await field.expectKeyAbsent('m'); + await field.expectKeyAbsent('y'); + await field.expectKeyAbsent('k'); + await field.expectKeyAbsent('e'); + }); + + test('save reloads and persists first key-value entry @critical', async ({ page }) => { + const title = `E2E KV ${faker.lorem.word()}`; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.expectVisible(); + await field.addEntry('mykey', 'myvalue'); + + await saveAndReload(page, title); + + const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await reloadedField.expectVisible(); + await reloadedField.expectEntryCount(1); + await reloadedField.expectEntry('mykey', 'myvalue'); + }); + + test('save reloads and persists multiple key-value entries @critical', async ({ page }) => { + const title = `E2E KV Multi ${faker.lorem.word()}`; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.expectVisible(); + await field.addEntry('firstKey', 'firstValue'); + await field.addEntry('secondKey', 'secondValue'); + + await field.expectEntryCount(2); + await field.expectEntry('firstKey', 'firstValue'); + await field.expectEntry('secondKey', 'secondValue'); + + await saveAndReload(page, title); + + const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await reloadedField.expectEntryCount(2); + await reloadedField.expectEntry('firstKey', 'firstValue'); + await reloadedField.expectEntry('secondKey', 'secondValue'); + }); + + test('edit key-value entry value persists after save and reload @smoke', async ({ + page + }) => { + const title = `E2E KV Edit ${faker.lorem.word()}`; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.addEntry('editKey', 'originalValue'); + await field.editEntryValue('editKey', 'updatedValue'); + await field.expectEntry('editKey', 'updatedValue'); + + await saveAndReload(page, title); + + const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await reloadedField.expectEntry('editKey', 'updatedValue'); + }); + + test('delete key-value entry persists after save and reload @smoke', async ({ page }) => { + const title = `E2E KV Delete ${faker.lorem.word()}`; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.addEntry('keepKey', 'keepValue'); + await field.addEntry('removeKey', 'removeValue'); + await field.expectEntryCount(2); + + await field.deleteEntryByKey('removeKey'); + await field.expectEntryCount(1); + await field.expectEntry('keepKey', 'keepValue'); + await field.expectKeyAbsent('removeKey'); + + await saveAndReload(page, title); + + const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await reloadedField.expectEntryCount(1); + await reloadedField.expectEntry('keepKey', 'keepValue'); + await reloadedField.expectKeyAbsent('removeKey'); + }); + }); + + test.describe('Regression #36318 with custom field', () => { + let contentType: ContentType | null = null; + let contentTypeVariable: string; + + test.beforeEach(async ({ request }) => { + contentType = await createFakeContentType(request, { + name: `E2EKeyValueCustom${uniqueSuffix()}`, + fields: [ + createFakePayloadTextField({ + name: 'Title', + variable: 'title', + sortOrder: 1 + }), + createFakePayloadCustomField({ + name: 'Custom Field', + variable: CUSTOM_FIELD_VARIABLE, + sortOrder: 2 + }), + createFakePayloadKeyValueField({ + name: 'Key Value Field', + variable: KEY_VALUE_FIELD_VARIABLE, + sortOrder: 3 + }) + ] + }); + contentTypeVariable = contentType.variable; + }); + + test.afterEach(async ({ request }) => { + if (contentType) { + await deleteContentType(request, contentType.id); + contentType = null; + } + }); + + test('first key-value entry is not split when custom field is present @critical', async ({ + page + }) => { + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + await expect(page.getByTestId(`field-${CUSTOM_FIELD_VARIABLE}`)).toBeVisible({ + timeout: 15000 + }); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.expectVisible(); + await field.addEntry('mykey', 'myvalue'); + + await field.expectEntryCount(1); + await field.expectEntry('mykey', 'myvalue'); + await field.expectKeyAbsent('m'); + await field.expectKeyAbsent('y'); + await field.expectKeyAbsent('k'); + await field.expectKeyAbsent('e'); + }); + + test('save reloads and persists key-value entry with custom field present @critical', async ({ + page + }) => { + const title = `E2E KV Custom ${faker.lorem.word()}`; + const formPage = new NewEditContentFormPage(page); + await formPage.goToNew(contentTypeVariable); + + await expect(page.getByTestId(`field-${CUSTOM_FIELD_VARIABLE}`)).toBeVisible({ + timeout: 15000 + }); + + const field = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await field.addEntry('mykey', 'myvalue'); + + await saveAndReload(page, title); + + const reloadedField = new KeyValueField(page, KEY_VALUE_FIELD_VARIABLE); + await reloadedField.expectEntryCount(1); + await reloadedField.expectEntry('mykey', 'myvalue'); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.spec.ts index 0413b423518d..bc64c6638005 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.spec.ts @@ -557,5 +557,58 @@ describe('DotEditContentFormResolutions', () => { expect(resolutionValue[fieldType]).toBe(resolutionValue[FIELD_TYPES.DATE]); }); }); + + describe('dateResolutionFn', () => { + const dateField: DotCMSContentTypeField = { + ...mockField, + variable: 'campoDate', + fieldType: FIELD_TYPES.DATE, + dataType: 'DATE' + }; + + it('should return numeric timestamp from contentlet', () => { + const contentlet = { + ...mockContentlet, + campoDate: 1736899200000 + }; + + expect(resolutionValue[FIELD_TYPES.DATE](contentlet, dateField)).toBe( + 1736899200000 + ); + }); + + it('should parse ISO date strings from contentlet', () => { + const isoDate = '2025-01-15T10:30:00.000Z'; + const contentlet = { + ...mockContentlet, + campoDate: isoDate + }; + + expect(resolutionValue[FIELD_TYPES.DATE](contentlet, dateField)).toBe( + Date.parse(isoDate) + ); + }); + + it('should parse formatted date strings from contentlet', () => { + const formattedDate = '2025-01-15'; + const contentlet = { + ...mockContentlet, + campoDate: formattedDate + }; + + expect(resolutionValue[FIELD_TYPES.DATE](contentlet, dateField)).toBe( + Date.parse(formattedDate) + ); + }); + + it('should return null for empty date values', () => { + const contentlet = { + ...mockContentlet, + campoDate: '' + }; + + expect(resolutionValue[FIELD_TYPES.DATE](contentlet, dateField)).toBeNull(); + }); + }); }); }); diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.ts index fc6a08b40fa4..71cf38822691 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/dot-edit-content-form-resolutions.ts @@ -6,7 +6,10 @@ import { import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; import { EditContentQueryParams } from '../../store/edit-content.store'; -import { getSingleSelectableFieldOptions } from '../../utils/functions.util'; +import { + getSingleSelectableFieldOptions, + parseCalendarTimestamp +} from '../../utils/functions.util'; import { getRelationshipFromContentlet } from '../../utils/relationshipFromContentlet'; /** @@ -171,55 +174,19 @@ const dateResolutionFn: FnResolutionValue = (contentlet, field) = } const value = contentlet[field.variable]; + const timestamp = parseCalendarTimestamp(value); - // If field doesn't exist in contentlet or is explicitly null/undefined/empty - if (value === null || value === undefined || value === '') { - return null; - } - - // Backend should always return number timestamps - if (typeof value === 'number') { - // Validate it's a reasonable timestamp (not NaN or invalid) - return isNaN(value) || !isFinite(value) ? null : value; - } - - // Handle edge cases where backend might return string timestamps - if (typeof value === 'string') { - const numericValue = Number(value); - if (!isNaN(numericValue) && isFinite(numericValue)) { - return numericValue; - } - - console.warn(`Calendar field received unexpected string value from backend:`, { + // Preserve diagnostics: backend should always return numeric timestamps, so a + // non-empty value that fails to parse signals an unexpected payload worth logging. + if (timestamp == null && value != null && value !== '') { + console.warn('Calendar field received unexpected value from backend:', { fieldVariable: field.variable, - value: value, + value, type: typeof value }); - return null; - } - - // Handle unexpected Date objects (shouldn't happen from backend) - if (value instanceof Date) { - const timestamp = value.getTime(); - if (!isNaN(timestamp)) { - console.warn(`Calendar field received Date object instead of timestamp from backend:`, { - fieldVariable: field.variable, - value: value, - convertedTimestamp: timestamp - }); - return timestamp; - } - return null; } - // Log unexpected value types - console.error(`Calendar field received unexpected value type from backend:`, { - fieldVariable: field.variable, - value: value, - type: typeof value - }); - - return null; + return timestamp ?? null; }; /** diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.ts index da87de99ee50..15a19bd90579 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.ts @@ -194,8 +194,8 @@ export class DotCalendarFieldComponent extends BaseControlValueAccessor): DotKeyValue[] { - if (!data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) { return []; } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-key-value/dot-edit-content-key-value.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-key-value/dot-edit-content-key-value.component.spec.ts index 321862e78bcf..20b58a3ff474 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-key-value/dot-edit-content-key-value.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-key-value/dot-edit-content-key-value.component.spec.ts @@ -215,6 +215,22 @@ describe('DotEditContentKeyValueComponent', () => { { key: 'key3', value: 'value3' } ]); }); + + it('should not split string values into individual characters', () => { + const keyValueField = spectator.query(DotKeyValueFieldComponent); + keyValueField.writeValue('[object Object]' as unknown as Record); + spectator.detectChanges(); + + expect(keyValueField.$initialValue()).toEqual([]); + }); + + it('should not split array values into individual entries', () => { + const keyValueField = spectator.query(DotKeyValueFieldComponent); + keyValueField.writeValue(['key1', 'key2'] as unknown as Record); + spectator.detectChanges(); + + expect(keyValueField.$initialValue()).toEqual([]); + }); }); describe('should handle updateField method', () => { diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts index b80a015cde5c..23004e3b9e29 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts @@ -55,6 +55,7 @@ describe('Utils Functions', () => { isFlattenedField, isCalendarField, processCalendarFieldValue, + parseCalendarTimestamp, processFieldValue } = functionsUtil; @@ -572,19 +573,24 @@ describe('Utils Functions', () => { }); describe('DATE field', () => { - it('should convert value to string for DATE field', () => { + it('should preserve numeric timestamp for DATE field', () => { + const value = 1736899200000; + const field = { fieldType: 'Date', dataType: 'DATE' } as DotCMSContentTypeField; + + expect(getFinalCastedValue(value, field)).toEqual(value); + }); + + it('should preserve ISO string for DATE field', () => { const value = '2021-09-01T18:00:00.000Z'; const field = { fieldType: 'Date', dataType: 'DATE' } as DotCMSContentTypeField; - // DATE field goes through castSingleSelectableValue which returns String(value) expect(getFinalCastedValue(value, field)).toEqual(value); }); - it("should convert 'now' to string for DATE field", () => { + it("should preserve 'now' for DATE field", () => { const value = 'now'; const field = { fieldType: 'Date', dataType: 'DATE' } as DotCMSContentTypeField; - // DATE field goes through castSingleSelectableValue which returns String(value) expect(getFinalCastedValue(value, field)).toEqual(value); }); @@ -1592,6 +1598,20 @@ describe('Utils Functions', () => { expect(processCalendarFieldValue(invalidString, fieldName)).toBeNull(); }); + it('should parse ISO date strings', () => { + const isoDate = '2025-01-15T10:30:00.000Z'; + const expected = Date.parse(isoDate); + + expect(processCalendarFieldValue(isoDate, fieldName)).toBe(expected); + }); + + it('should parse formatted date strings', () => { + const formattedDate = '2025-01-15'; + const expected = Date.parse(formattedDate); + + expect(processCalendarFieldValue(formattedDate, fieldName)).toBe(expected); + }); + it('should return null for empty string after trim', () => { const emptyString = ' '; @@ -1628,15 +1648,6 @@ describe('Utils Functions', () => { consoleErrorSpy.mockRestore(); }); - it('should log warning for string timestamp conversion', () => { - processCalendarFieldValue('1737021000000', fieldName); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - `Calendar field ${fieldName} received string timestamp, converted to number:`, - { original: '1737021000000', converted: 1737021000000 } - ); - }); - it('should log warning for invalid string timestamps', () => { processCalendarFieldValue('invalid', fieldName); @@ -1646,6 +1657,12 @@ describe('Utils Functions', () => { ); }); + it('should not log warning for valid numeric string timestamps', () => { + processCalendarFieldValue('1737021000000', fieldName); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + it('should log error for unexpected value types', () => { const unexpectedValue = { test: 'value' }; processCalendarFieldValue(unexpectedValue as any, fieldName); @@ -1658,6 +1675,100 @@ describe('Utils Functions', () => { }); }); + describe('parseCalendarTimestamp', () => { + describe('null/undefined handling', () => { + it('should return null for null value', () => { + expect(parseCalendarTimestamp(null)).toBeNull(); + }); + + it('should return undefined for undefined value', () => { + expect(parseCalendarTimestamp(undefined)).toBeUndefined(); + }); + + it('should return null for empty string', () => { + expect(parseCalendarTimestamp('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(parseCalendarTimestamp(' ')).toBeNull(); + }); + }); + + describe('number handling', () => { + it('should return valid numbers as-is', () => { + const timestamp = 1737021000000; + + expect(parseCalendarTimestamp(timestamp)).toBe(timestamp); + }); + + it('should handle zero', () => { + expect(parseCalendarTimestamp(0)).toBe(0); + }); + + it('should handle negative timestamps', () => { + expect(parseCalendarTimestamp(-1737021000000)).toBe(-1737021000000); + }); + + it('should return null for NaN', () => { + expect(parseCalendarTimestamp(NaN)).toBeNull(); + }); + + it('should return null for Infinity', () => { + expect(parseCalendarTimestamp(Infinity)).toBeNull(); + expect(parseCalendarTimestamp(-Infinity)).toBeNull(); + }); + }); + + describe('Date object handling', () => { + it('should convert valid Date objects to timestamps', () => { + const date = new Date('2025-01-15T10:30:00Z'); + + expect(parseCalendarTimestamp(date)).toBe(date.getTime()); + }); + + it('should return null for invalid Date objects', () => { + expect(parseCalendarTimestamp(new Date('invalid'))).toBeNull(); + }); + }); + + describe('string handling', () => { + it('should convert numeric strings to numbers', () => { + expect(parseCalendarTimestamp('1737021000000')).toBe(1737021000000); + }); + + it('should trim before parsing numeric strings', () => { + expect(parseCalendarTimestamp(' 1737021000000 ')).toBe(1737021000000); + }); + + it('should parse ISO date strings', () => { + const isoDate = '2025-01-15T10:30:00.000Z'; + + expect(parseCalendarTimestamp(isoDate)).toBe(Date.parse(isoDate)); + }); + + it('should parse formatted date strings', () => { + const formattedDate = '2025-01-15'; + + expect(parseCalendarTimestamp(formattedDate)).toBe(Date.parse(formattedDate)); + }); + + it('should return null for non-parseable strings', () => { + expect(parseCalendarTimestamp('not-a-date')).toBeNull(); + }); + }); + + describe('unexpected value handling', () => { + it('should return null for object values', () => { + expect(parseCalendarTimestamp({ timestamp: 1737021000000 })).toBeNull(); + }); + + it('should return null for boolean values', () => { + expect(parseCalendarTimestamp(true)).toBeNull(); + expect(parseCalendarTimestamp(false)).toBeNull(); + }); + }); + }); + describe('processFieldValue', () => { describe('flattened fields', () => { it('should join array values with commas for flattened fields', () => { diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.ts index 8ebba73d00f5..fa1774398ad3 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.ts @@ -13,7 +13,6 @@ import { UVE_MODE } from '@dotcms/types'; import { CustomFieldConfig } from '../models/dot-edit-content-custom-field.interface'; import { CALENDAR_FIELD_TYPES, - CALENDAR_FIELD_TYPES_WITH_TIME, DEFAULT_CUSTOM_FIELD_CONFIG, FLATTENED_FIELD_TYPES, TAB_FIELD_CLAZZ, @@ -169,7 +168,7 @@ export const getFinalCastedValue = ( value: object | string | number | undefined, field: DotCMSContentTypeField ) => { - if (CALENDAR_FIELD_TYPES_WITH_TIME.includes(field.fieldType as FIELD_TYPES)) { + if (CALENDAR_FIELD_TYPES.includes(field.fieldType as FIELD_TYPES)) { return value; } @@ -619,73 +618,80 @@ export const isCalendarField = (field: DotCMSContentTypeField): boolean => { }; /** - * Processes calendar field values to ensure they are always numeric timestamps. - * Handles conversion from Date objects, strings, and validates numeric values. + * Parses a calendar value from the backend or form into a numeric UTC timestamp. + * Accepts numbers, numeric strings, ISO/formatted date strings, and Date objects. * - * @param fieldValue - The calendar field value (Date, number, string, or null/undefined) - * @param fieldName - The field name (for logging purposes) - * @returns Numeric timestamp or null/undefined + * @param value - Raw calendar field value + * @returns Numeric timestamp, null for empty/invalid, or undefined when value is undefined */ -export const processCalendarFieldValue = ( - fieldValue: unknown, - fieldName: string -): number | null | undefined => { - // Handle null/undefined values - if (fieldValue === null || fieldValue === undefined) { - return fieldValue as null | undefined; +export const parseCalendarTimestamp = (value: unknown): number | null | undefined => { + if (value === null || value === undefined) { + return value as null | undefined; } - // Handle empty strings - if (fieldValue === '') { + if (value === '') { return null; } - // Convert Date objects to timestamps (normal case from calendar component) - if (fieldValue instanceof Date) { - return fieldValue.getTime(); + if (typeof value === 'number') { + return isNaN(value) || !isFinite(value) ? null : value; } - // Keep numeric values as-is (already correct timestamps) - if (typeof fieldValue === 'number') { - return fieldValue; + if (value instanceof Date) { + const timestamp = value.getTime(); + + return isNaN(timestamp) ? null : timestamp; } - // Convert string timestamps to numbers (edge case - from form state) - if (typeof fieldValue === 'string') { - const trimmedValue = fieldValue.trim(); + if (typeof value === 'string') { + const trimmedValue = value.trim(); - // Handle empty string after trim if (trimmedValue === '') { return null; } const numericValue = Number(trimmedValue); - if (isNaN(numericValue)) { - console.warn(`Calendar field ${fieldName} has invalid timestamp string:`, fieldValue); - return null; + if (!isNaN(numericValue) && isFinite(numericValue)) { + return numericValue; } - console.warn( - `Calendar field ${fieldName} received string timestamp, converted to number:`, - { - original: fieldValue, - converted: numericValue - } - ); + const parsed = Date.parse(trimmedValue); - return numericValue; + return isNaN(parsed) ? null : parsed; } - // Handle unexpected cases (arrays, objects, etc.) - console.error(`Calendar field ${fieldName} received unexpected value:`, { - value: fieldValue, - type: typeof fieldValue - }); - return null; }; +/** + * Processes calendar field values to ensure they are always numeric timestamps. + * Handles conversion from Date objects, strings, and validates numeric values. + * + * @param fieldValue - The calendar field value (Date, number, string, or null/undefined) + * @param fieldName - The field name (for logging purposes) + * @returns Numeric timestamp or null/undefined + */ +export const processCalendarFieldValue = ( + fieldValue: unknown, + fieldName: string +): number | null | undefined => { + const parsed = parseCalendarTimestamp(fieldValue); + + if (parsed === null && fieldValue !== null && fieldValue !== undefined && fieldValue !== '') { + if (typeof fieldValue === 'string') { + console.warn(`Calendar field ${fieldName} has invalid timestamp string:`, fieldValue); + } else if (typeof fieldValue !== 'number' && !(fieldValue instanceof Date)) { + console.error(`Calendar field ${fieldName} received unexpected value:`, { + value: fieldValue, + type: typeof fieldValue + }); + } + } + + return parsed; +}; + /** * Processes a single field value based on its field type. * Applies appropriate transformations for different field types: diff --git a/dotCMS/src/main/webapp/html/legacy_custom_field/field-interceptors.js b/dotCMS/src/main/webapp/html/legacy_custom_field/field-interceptors.js index f87904e002b8..d006e6a36af7 100644 --- a/dotCMS/src/main/webapp/html/legacy_custom_field/field-interceptors.js +++ b/dotCMS/src/main/webapp/html/legacy_custom_field/field-interceptors.js @@ -226,6 +226,7 @@ class DotFieldInterceptorManager { if (input._dotIntercepted) return input; let isAngularUpdate = false; + const manager = this; const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); try { @@ -234,7 +235,7 @@ class DotFieldInterceptorManager { set(value) { const oldValue = valueDescriptor.get.apply(this); valueDescriptor.set.apply(this, [value]); - if (oldValue !== value && !isAngularUpdate) { + if (oldValue !== value && !isAngularUpdate && manager._dotAngularPush !== variable) { if (window.DotCustomFieldApi) { window.DotCustomFieldApi.set(variable, value); } @@ -289,6 +290,8 @@ class DotFieldInterceptorManager { * Installs global interceptors for input tracking and value changes */ installGlobalInterceptors() { + const manager = this; + // Intercept setAttribute calls const originalSetAttribute = HTMLInputElement.prototype.setAttribute; HTMLInputElement.prototype.setAttribute = function(name, value) { @@ -296,7 +299,7 @@ class DotFieldInterceptorManager { originalSetAttribute.call(this, name, value); if (name === 'value' && oldValue !== value && this.hasAttribute('data-angular-tracked')) { const variable = this.name || this.id; - if (variable && window.DotCustomFieldApi) { + if (variable && manager._dotAngularPush !== variable && window.DotCustomFieldApi) { window.DotCustomFieldApi.set(variable, value); } }