diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.html index 662f8cdec61f..86dcc00fbfb2 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.html @@ -20,8 +20,54 @@ [showIcon]="true" [icon]="fieldTypeConfig.icon" [showButtonBar]="true" - [showClear]="$isExpireDateField()" + [showClear]="true" [placeholder]=" $isExpireDateField() ? ('edit.content.form.field.calendar.never.expires' | dm) : '' " - [attr.aria-label]="field.name" /> + [attr.aria-label]="field.name"> + + + +
+ @if ($showFooterTimezone()) { + + {{ $systemTimezone().label }} + + } + +
+
+ + + + + + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.scss index 2dd60fd15128..14f1214ffb12 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.scss @@ -2,14 +2,21 @@ // the ring encloses both the input and the calendar icon button as one unit. :host ::ng-deep p-datepicker { - // Use PrimeNG's default datepicker sizing - display: inline-flex; + // Block-level flex so the control fills the column it sits in, matching every other field + // type. The input is the part that grows; the trigger keeps its intrinsic width and stays + // flush against the right edge. + display: flex; + width: 100%; border-radius: var(--p-inputtext-border-radius); + transition: border-color var(--p-inputtext-transition-duration), box-shadow var(--p-inputtext-transition-duration); .p-datepicker-input { + // Grows so the input, not the wrapper, absorbs the column's extra width. + flex: 1 1 auto; + min-width: 0; border-right: 0; } @@ -51,3 +58,23 @@ border-color: var(--p-inputtext-invalid-border-color); } } + +// The clear control projected through PrimeNG's #clearicon template. PrimeNG positions the +// slot; this only strips the button chrome so it reads as the icon it replaces, while staying +// a real focusable button (see calendar-field.component.html). +:host ::ng-deep .calendar-field__clear { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; + + &:focus-visible { + outline: var(--p-focus-ring-width) solid var(--p-focus-ring-color); + outline-offset: var(--p-focus-ring-offset); + border-radius: var(--p-border-radius-sm); + } +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.spec.ts new file mode 100644 index 000000000000..a842dbd918e3 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.spec.ts @@ -0,0 +1,476 @@ +import { SpectatorHost, createHostFactory, mockProvider } from '@openng/spectator/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Component } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { DatePicker } from 'primeng/datepicker'; + +import { DotMessageService } from '@dotcms/data-access'; +import { + DotCMSContentType, + DotCMSContentTypeField, + DotSystemTimezone +} from '@dotcms/dotcms-models'; + +import { DotCalendarFieldComponent } from './calendar-field.component'; + +import { FIELD_TYPES } from '../../../../models/dot-edit-content-field.enum'; +import { CONTENT_TYPE_MOCK, DATE_FIELD_MOCK } from '../../../../utils/mocks'; + +/** + * Host for the calendar field, which is a ControlValueAccessor and therefore has to be + * driven through a real reactive form rather than by setting inputs directly. + */ +@Component({ + standalone: false, + selector: 'dot-custom-host', + template: '' +}) +export class MockFormComponent { + formGroup: FormGroup; + field: DotCMSContentTypeField; + utcTimezone: DotSystemTimezone | null; + contentType: DotCMSContentType | null; + hasError = false; +} + +describe('DotCalendarFieldComponent', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: DotCalendarFieldComponent, + host: MockFormComponent, + imports: [ReactiveFormsModule], + detectChanges: false, + providers: [ + mockProvider(DotMessageService, { + get: vi.fn().mockImplementation((key: string) => key) + }) + ] + }); + + /** Offset chosen so the server can sit on a different calendar day than the runner. */ + const MOCK_TIMEZONE: DotSystemTimezone = { + id: 'America/New_York', + label: 'Eastern Time (GMT-5)', + offset: -18000000 + }; + + const CONTENT_TYPE_WITHOUT_EXPIRE = { + ...CONTENT_TYPE_MOCK, + expireDateVar: null + }; + + const TEMPLATE = `
+ + `; + + /** + * Builds the host with a single control of the given field type. + * + * @param fieldType which of the three calendar types to render + * @param value initial value of the form control, as a UTC timestamp + * @param overrides field/timezone/contentType overrides for the case under test + */ + const buildHost = ( + fieldType: FIELD_TYPES, + value: number | null = null, + overrides: Partial = {} + ): SpectatorHost => { + const field = { ...DATE_FIELD_MOCK, fieldType, ...((overrides.field as object) ?? {}) }; + + return createHost(TEMPLATE, { + hostProps: { + formGroup: new FormGroup({ + [field.variable]: new FormControl(value) + }), + field, + utcTimezone: MOCK_TIMEZONE, + contentType: CONTENT_TYPE_WITHOUT_EXPIRE, + hasError: false, + ...overrides + } + }); + }; + + /** All three types, for the assertions that must hold identically across them. */ + const ALL_TYPES = [FIELD_TYPES.DATE, FIELD_TYPES.TIME, FIELD_TYPES.DATE_AND_TIME] as const; + + /** A value the picker will render into the input, so the clear control becomes eligible. */ + const A_VALUE = new Date(2026, 3, 9, 9, 33).getTime(); + + /** + * The clear control as the user meets it: something focusable and activatable, not the + * bare PrimeNG renders by default. Queried by role rather than by class so the + * test fails if the control is present but not a real button. + */ + const queryClearControl = (): HTMLElement | null => + spectator.query('[data-testid="calendar-clear-button"]'); + + /** + * One host change-detection pass, then a microtask turn, then another pass. + * + * Deliberately NOT the DatePicker's own detector. An earlier version of this helper called + * `picker.cd.detectChanges()`, which turned these tests green while the real app was broken: + * on the load path nothing supplies that pass, so a saved contentlet rendered its value with + * no way to clear it. The component now schedules the pass itself in a microtask; the test + * only has to let that microtask run, the same turn the browser gives it. + */ + const settle = async (): Promise => { + spectator.detectChanges(); + await Promise.resolve(); + spectator.detectChanges(); + }; + + /** + * Opens the picker overlay. The overlay is appended to `body`, so it lives outside the + * fixture and has to be queried through the document rather than through Spectator. + */ + const openPicker = (): void => { + const picker = spectator.query(DatePicker); + picker.showOverlay(); + spectator.detectChanges(); + picker.cd.detectChanges(); + }; + + /** Queries inside the open overlay panel, which Spectator's own queries cannot reach. */ + const queryInOverlay = (selector: string): HTMLElement | null => + document.querySelector(`.p-datepicker-panel ${selector}`); + + afterEach(() => { + // The body-appended overlay outlives the fixture; left behind it would leak into the + // next test's document queries. + document.querySelectorAll('.p-datepicker-panel').forEach((panel) => panel.remove()); + }); + + // One host per test: Spectator's createHost instantiates the TestBed, so it cannot be + // called twice in the same `it`. + it.each([...ALL_TYPES])('should build the harness for a %s field', async (fieldType) => { + spectator = buildHost(fieldType); + spectator.detectChanges(); + + expect(spectator.component).toBeTruthy(); + }); + + describe('Clear control on the field (US1)', () => { + // T005 — FR-006 + it.each([...ALL_TYPES])( + 'should NOT render a clear control on an empty %s field', + async (fieldType) => { + spectator = buildHost(fieldType, null); + await settle(); + + expect(queryClearControl()).toBeNull(); + } + ); + + // T006 — FR-005 + it.each([...ALL_TYPES])( + 'should render a clear control on a %s field holding a value', + async (fieldType) => { + spectator = buildHost(fieldType, A_VALUE); + await settle(); + + expect(queryClearControl()).not.toBeNull(); + } + ); + + // T007 — FR-006 + it('should NOT render a clear control when the control is disabled, despite holding a value', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, A_VALUE); + await settle(); + + spectator.hostComponent.formGroup.get(DATE_FIELD_MOCK.variable)?.disable(); + await settle(); + + expect(queryClearControl()).toBeNull(); + }); + + // T008 — FR-007 + it('should empty the value and mark the control touched and dirty when cleared', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, A_VALUE); + await settle(); + + const control = spectator.hostComponent.formGroup.get(DATE_FIELD_MOCK.variable); + expect(control?.value).toBe(A_VALUE); + + spectator.click(queryClearControl() as HTMLElement); + await settle(); + + expect(control?.value).toBeNull(); + expect(spectator.component.internalFormControl.value).toBeNull(); + expect(control?.touched).toBe(true); + expect(control?.dirty).toBe(true); + }); + + // T009 — FR-007a. The part PrimeNG does not provide: its default clear icon is a bare + // with a click handler — unfocusable, unnamed, unreachable by keyboard. + it('should expose the clear control as a focusable button with an accessible name', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, A_VALUE); + await settle(); + + const clear = queryClearControl(); + + expect(clear?.tagName).toBe('BUTTON'); + expect(clear?.getAttribute('type')).toBe('button'); + expect(clear?.getAttribute('aria-label')).toBeTruthy(); + }); + + it('should clear the value when the clear button is activated from the keyboard', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, A_VALUE); + await settle(); + + const control = spectator.hostComponent.formGroup.get(DATE_FIELD_MOCK.variable); + const clear = queryClearControl() as HTMLButtonElement; + + clear.focus(); + expect(document.activeElement).toBe(clear); + + // A native button turns Enter/Space into a click; asserting the click path from a + // focused element is what proves it is reachable without a mouse. + clear.click(); + await settle(); + + expect(control?.value).toBeNull(); + }); + + // T058 — FR-018 and the "Clearing a field that carries a default value" edge case. + // Only reachable because FR-005 made clearing available on every field type; before + // this feature only the expire-date field had a clear control. `handleChangeValue`'s + // null branch re-applies `processFieldDefaultValue` and pushes it back out through + // `onChange`, so an explicit clear must not be mistaken for an unpopulated field. + it.each(['now', '2026-04-09 09:33:00'])( + 'should stay empty after clearing a field whose defaultValue is %s', + async (defaultValue) => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, null, { + field: { ...DATE_FIELD_MOCK, defaultValue } as DotCMSContentTypeField + }); + await settle(); + + const control = spectator.hostComponent.formGroup.get(DATE_FIELD_MOCK.variable); + + // The default populates the field on open, which is what makes it clearable. + expect(control?.value).not.toBeNull(); + expect(queryClearControl()).not.toBeNull(); + + spectator.click(queryClearControl() as HTMLElement); + await settle(); + + expect(control?.value).toBeNull(); + expect(spectator.component.internalFormControl.value).toBeNull(); + expect(queryClearControl()).toBeNull(); + } + ); + + // T010 — FR-007 + it('should leave a required field empty and invalid when cleared', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, A_VALUE, { + field: { ...DATE_FIELD_MOCK, required: true } as DotCMSContentTypeField + }); + await settle(); + + const control = spectator.hostComponent.formGroup.get(DATE_FIELD_MOCK.variable); + control?.setValidators(Validators.required); + control?.updateValueAndValidity(); + + spectator.click(queryClearControl() as HTMLElement); + await settle(); + + expect(control?.value).toBeNull(); + expect(control?.invalid).toBe(true); + }); + }); + + describe('Picker footer — timezone (US2)', () => { + // T018 — FR-008 + it('should show the timezone in the picker footer for a Date-and-Time field', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME); + openPicker(); + + const timezone = queryInOverlay('[data-testid="calendar-field-timezone"]'); + + expect(timezone).not.toBeNull(); + expect(timezone?.textContent).toContain(MOCK_TIMEZONE.label); + }); + + // T019 — FR-008 + it('should show the timezone in the picker footer for a Time field', async () => { + spectator = buildHost(FIELD_TYPES.TIME); + openPicker(); + + const timezone = queryInOverlay('[data-testid="calendar-field-timezone"]'); + + expect(timezone).not.toBeNull(); + expect(timezone?.textContent).toContain(MOCK_TIMEZONE.label); + }); + + // T020 — FR-008a. A date carries no time, so the zone it would be read in is not a fact + // the author needs. Decided explicitly in the issue's refinement table. + it('should NOT show the timezone in the picker footer for a Date field', async () => { + spectator = buildHost(FIELD_TYPES.DATE); + openPicker(); + + expect(queryInOverlay('[data-testid="calendar-field-timezone"]')).toBeNull(); + }); + + it('should render the timezone as non-interactive text, not a control', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME); + openPicker(); + + const timezone = queryInOverlay('[data-testid="calendar-field-timezone"]'); + + expect(timezone?.tagName).not.toBe('BUTTON'); + expect(timezone?.tagName).not.toBe('A'); + expect(timezone?.getAttribute('tabindex')).toBeNull(); + }); + }); + + describe('Picker footer — Today / Now (US3)', () => { + // 02:00 UTC on 9 April. New York is then still on 8 April, 22:00 — so every assertion + // below can tell the server's calendar day apart from the runner's. + const FAKE_NOW_UTC = new Date('2026-04-09T02:00:00.000Z'); + const SERVER_DAY = 8; + const SERVER_HOUR = 22; + + beforeEach(() => { + // Only Date is faked: faking timers wholesale interferes with zone.js scheduling. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(FAKE_NOW_UTC); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** + * The inner
-
+
+ +
+ @if (fieldHasError && isRequired) { - + {{ 'dot.edit.content.form.field.required' | dm }} } - @if (!fieldHasError && !hasTimezone && hasHint) { + @if (hasHint) { + class="p-field-hint text-sm text-gray-500" + [attr.data-testId]="'hint-' + field.variable"> {{ field.hint }} } - @if (!fieldHasError && hasTimezone && hasHint) { - - {{ systemTimezone.label }} - - }
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.spec.ts index f3b36fd59735..a3df0fce25c5 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.spec.ts @@ -69,34 +69,43 @@ describe('DotEditContentCalendarFieldComponent', () => { expireDateVar: null }; - describe('Calendar field timezone information', () => { - it('should show timezone info for DATE_AND_TIME fields when timezone is provided', () => { - spectator = createHost( - `
- - `, - { - hostProps: { - formGroup: new FormGroup({ - [DATE_FIELD_MOCK.variable]: new FormControl() - }), - field: { ...DATE_FIELD_MOCK, fieldType: FIELD_TYPES.DATE_AND_TIME }, - utcTimezone: MOCK_TIMEZONE, - contentType: CONTENT_TYPE_WITHOUT_EXPIRE, - contentlet: createFakeContentlet({ - [DATE_FIELD_MOCK.variable]: null - }) + // The 'Calendar field timezone information' suite that lived here tested the timezone line + // under the input. FR-009 removes that line entirely — the timezone now renders inside the + // picker footer, so its coverage moved to calendar-field.component.spec.ts ('Picker footer — + // timezone'). What remains here is the guarantee that nothing renders under the input. + describe('Calendar field timezone placement', () => { + it.each([FIELD_TYPES.DATE_AND_TIME, FIELD_TYPES.TIME, FIELD_TYPES.DATE])( + 'should NOT render a timezone line under the input for a %s field', + (fieldType) => { + const field = { ...DATE_FIELD_MOCK, fieldType }; + + spectator = createHost( + `
+ + `, + { + hostProps: { + formGroup: new FormGroup({ + [field.variable]: new FormControl() + }), + field, + utcTimezone: MOCK_TIMEZONE, + contentType: CONTENT_TYPE_WITHOUT_EXPIRE, + contentlet: createFakeContentlet({ [field.variable]: null }) + } } - } - ); - spectator.detectChanges(); + ); + spectator.detectChanges(); - const timezoneElement = spectator.query(byTestId('calendar-field-timezone')); - expect(timezoneElement).toExist(); - expect(timezoneElement).toContainText(MOCK_TIMEZONE.label); - }); + expect(spectator.query(byTestId('calendar-field-timezone'))).not.toExist(); + } + ); + }); + + describe('Calendar field hint', () => { + it('should NOT show hint when field has no hint property', () => { + const fieldWithoutHint = { ...DATE_FIELD_MOCK, hint: undefined }; - it('should show timezone info for TIME fields when timezone is provided', () => { spectator = createHost( `
@@ -104,25 +113,32 @@ describe('DotEditContentCalendarFieldComponent', () => { { hostProps: { formGroup: new FormGroup({ - [DATE_FIELD_MOCK.variable]: new FormControl() + [fieldWithoutHint.variable]: new FormControl() }), - field: { ...DATE_FIELD_MOCK, fieldType: FIELD_TYPES.TIME }, - utcTimezone: MOCK_TIMEZONE, + field: fieldWithoutHint, + utcTimezone: null, contentType: CONTENT_TYPE_WITHOUT_EXPIRE, contentlet: createFakeContentlet({ - [DATE_FIELD_MOCK.variable]: null + [fieldWithoutHint.variable]: null }) } } ); spectator.detectChanges(); - const timezoneElement = spectator.query(byTestId('calendar-field-timezone')); - expect(timezoneElement).toExist(); - expect(timezoneElement).toContainText(MOCK_TIMEZONE.label); + const hintElement = spectator.query(byTestId(`hint-${fieldWithoutHint.variable}`)); + expect(hintElement).not.toExist(); }); - it('should NOT show timezone info for DATE fields', () => { + // T022 — FR-009. The timezone moves into the picker, so the field footer goes back to + // carrying the hint like every other field type does. + it('should render the hint under the input, and no timezone line, when a timezone is present', () => { + const fieldWithHint = { + ...DATE_FIELD_MOCK, + fieldType: FIELD_TYPES.DATE_AND_TIME, + hint: 'Pick the go-live date' + }; + spectator = createHost( ` @@ -130,24 +146,36 @@ describe('DotEditContentCalendarFieldComponent', () => { { hostProps: { formGroup: new FormGroup({ - [DATE_FIELD_MOCK.variable]: new FormControl() + [fieldWithHint.variable]: new FormControl() }), - field: { ...DATE_FIELD_MOCK, fieldType: FIELD_TYPES.DATE }, + field: fieldWithHint, utcTimezone: MOCK_TIMEZONE, contentType: CONTENT_TYPE_WITHOUT_EXPIRE, contentlet: createFakeContentlet({ - [DATE_FIELD_MOCK.variable]: null + [fieldWithHint.variable]: null }) } } ); spectator.detectChanges(); - const timezoneElement = spectator.query(byTestId('calendar-field-timezone')); - expect(timezoneElement).not.toExist(); + const hintElement = spectator.query(byTestId(`hint-${fieldWithHint.variable}`)); + expect(hintElement).toExist(); + expect(hintElement).toContainText(fieldWithHint.hint); + + expect(spectator.query(byTestId('calendar-field-timezone'))).not.toExist(); }); - it('should NOT show timezone info when no timezone is provided', () => { + // The required error must not evict the hint: the hint explains what to enter, which is + // exactly what the author needs while the field is in error. Error first, hint below it. + it('should show the required error AND keep the hint, error first', () => { + const fieldWithHint = { + ...DATE_FIELD_MOCK, + fieldType: FIELD_TYPES.DATE_AND_TIME, + required: true, + hint: 'Pick the go-live date' + }; + spectator = createHost( ` @@ -155,34 +183,44 @@ describe('DotEditContentCalendarFieldComponent', () => { { hostProps: { formGroup: new FormGroup({ - [DATE_FIELD_MOCK.variable]: new FormControl() + [fieldWithHint.variable]: new FormControl() }), - field: { ...DATE_FIELD_MOCK, fieldType: FIELD_TYPES.DATE_AND_TIME }, - utcTimezone: null, + field: fieldWithHint, + utcTimezone: MOCK_TIMEZONE, contentType: CONTENT_TYPE_WITHOUT_EXPIRE, contentlet: createFakeContentlet({ - [DATE_FIELD_MOCK.variable]: null + [fieldWithHint.variable]: null }) } } ); spectator.detectChanges(); - // El elemento timezone existe pero no debe mostrar contenido - const timezoneElement = spectator.query(byTestId('calendar-field-timezone')); - expect(timezoneElement).toBeNull(); + spectator.component.$hasError.set(true); + spectator.detectChanges(); + + const error = spectator.query('.p-field-error'); + const hint = spectator.query(byTestId(`hint-${fieldWithHint.variable}`)); + + expect(error).toExist(); + expect(hint).toExist(); + expect(hint).toContainText(fieldWithHint.hint); + + // Order matters: the error is the new information, the hint is the standing guidance. + expect( + error?.compareDocumentPosition(hint as Node) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); }); - }); - describe('Calendar field hint', () => { - it('should show hint when field has hint property', () => { + // T023 — FR-009. The label tooltip existed only to make room for the timezone line in + // the footer. With the timezone gone, so is the reason. + it('should NOT route the hint into the label tooltip when a timezone is present', () => { const fieldWithHint = { ...DATE_FIELD_MOCK, fieldType: FIELD_TYPES.DATE_AND_TIME, - hint: 'Test hint message' + hint: 'Pick the go-live date' }; - // Usar DATE_AND_TIME field para que showTimezoneInfo sea true y aparezca el contenedor spectator = createHost( ` @@ -203,15 +241,12 @@ describe('DotEditContentCalendarFieldComponent', () => { ); spectator.detectChanges(); - expect(fieldWithHint.hint).toBe('Test hint message'); - - const hintElement = spectator.query(Tooltip); - expect(hintElement).toExist(); + expect(spectator.query(Tooltip)).not.toExist(); }); + }); - it('should NOT show hint when field has no hint property', () => { - const fieldWithoutHint = { ...DATE_FIELD_MOCK, hint: undefined }; - + describe('Expire date field behavior', () => { + it('should show placeholder and showClear when field is expire date field', () => { spectator = createHost( ` @@ -219,26 +254,26 @@ describe('DotEditContentCalendarFieldComponent', () => { { hostProps: { formGroup: new FormGroup({ - [fieldWithoutHint.variable]: new FormControl() + [DATE_FIELD_MOCK.variable]: new FormControl() }), - field: fieldWithoutHint, + field: DATE_FIELD_MOCK, utcTimezone: null, - contentType: CONTENT_TYPE_WITHOUT_EXPIRE, + contentType: CONTENT_TYPE_WITH_EXPIRE, contentlet: createFakeContentlet({ - [fieldWithoutHint.variable]: null + [DATE_FIELD_MOCK.variable]: null }) } } ); spectator.detectChanges(); - const hintElement = spectator.query(byTestId('calendar-field-hint')); - expect(hintElement).not.toExist(); + const calendar = spectator.query(DatePicker); + expect(calendar.showClear).toBe(true); + + expect(calendar.placeholder).toBe('Never expires'); }); - }); - describe('Expire date field behavior', () => { - it('should show placeholder and showClear when field is expire date field', () => { + it('should NOT show the placeholder, but still allow clearing, when field is NOT expire date field', () => { spectator = createHost( ` @@ -250,7 +285,7 @@ describe('DotEditContentCalendarFieldComponent', () => { }), field: DATE_FIELD_MOCK, utcTimezone: null, - contentType: CONTENT_TYPE_WITH_EXPIRE, + contentType: CONTENT_TYPE_WITHOUT_EXPIRE, contentlet: createFakeContentlet({ [DATE_FIELD_MOCK.variable]: null }) @@ -260,12 +295,15 @@ describe('DotEditContentCalendarFieldComponent', () => { spectator.detectChanges(); const calendar = spectator.query(DatePicker); + // showClear is now unconditional (FR-005): every field type can be emptied, not + // just the expire-date one. The placeholder stays exclusive to the expire date. expect(calendar.showClear).toBe(true); - - expect(calendar.placeholder).toBe('Never expires'); + expect(calendar.placeholder).toBe(''); }); - it('should NOT show placeholder and showClear when field is NOT expire date field', () => { + // T011 — FR-007b. The expire-date field is the one field that could already be cleared, + // so making the clear control unconditional must not leave it with two of them. + it('should render exactly ONE clear control on the expire date field holding a value', () => { spectator = createHost( ` @@ -273,11 +311,13 @@ describe('DotEditContentCalendarFieldComponent', () => { { hostProps: { formGroup: new FormGroup({ - [DATE_FIELD_MOCK.variable]: new FormControl() + [DATE_FIELD_MOCK.variable]: new FormControl( + new Date(2026, 3, 9, 9, 33).getTime() + ) }), field: DATE_FIELD_MOCK, utcTimezone: null, - contentType: CONTENT_TYPE_WITHOUT_EXPIRE, + contentType: CONTENT_TYPE_WITH_EXPIRE, contentlet: createFakeContentlet({ [DATE_FIELD_MOCK.variable]: null }) @@ -285,10 +325,12 @@ describe('DotEditContentCalendarFieldComponent', () => { } ); spectator.detectChanges(); + // The DatePicker is OnPush and writes the input's DOM value without marking itself + // dirty, so the clear control's condition is only re-evaluated by its own detector. + spectator.query(DatePicker).cd.detectChanges(); - const calendar = spectator.query(DatePicker); - expect(calendar.showClear).toBe(false); - expect(calendar.placeholder).toBe(''); + expect(spectator.queryAll('[data-testid="calendar-clear-button"]')).toHaveLength(1); + expect(spectator.query(DatePicker).placeholder).toBe('Never expires'); }); }); @@ -483,11 +525,12 @@ describe('DotEditContentCalendarFieldComponent', () => { ); it.each(FIELD_TYPES_UNDER_TEST)( - 'should render the %s picker at PrimeNG default width, not full width', + 'should size the %s control from its stylesheet, not from utility classes', (_label, fieldType) => { buildHost(fieldType); - // No full-width override is applied; PrimeNG default sizing is used. + // The control fills its column (#37465 FR-001) through the component's own + // stylesheet, so no width utility class or inputStyleClass should appear here. const calendar = spectator.query(DatePicker); expect(calendar.inputStyleClass).toBeFalsy(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.ts index ebade7c0565e..5fa619507b03 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; import { DatePickerModule } from 'primeng/datepicker'; @@ -13,8 +13,6 @@ import { DotMessagePipe } from '@dotcms/ui'; import { DotCalendarFieldComponent } from './components/calendar-field/calendar-field.component'; -import { CALENDAR_FIELD_TYPES_WITH_TIME } from '../../models/dot-edit-content-field.constant'; -import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; 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'; @@ -89,12 +87,4 @@ export class DotEditContentCalendarFieldComponent extends BaseWrapperField { * Alias: contentType */ $contentType = input(null, { alias: 'contentType' }); - /** - * Whether to show timezone information. - * Only shown for fields that include time. - */ - $showTimezoneInfo = computed(() => { - const fieldType = this.$field().fieldType as FIELD_TYPES; // TODO: Fix fieldType on DotCMSContentTypeField to FieldType instead of string - return CALENDAR_FIELD_TYPES_WITH_TIME.includes(fieldType); - }); } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b816dfa8ee1c..ae39e8e655e3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7664,6 +7664,9 @@ analytics.engagement.platforms.empty=No data for the selected period. com.dotcms.repackage.javax.portlet.title.analytics-dashboard=Analytics Dashboard edit.content.form.field.calendar.never.expires=Never Expires +edit.content.form.field.calendar.today=Today +edit.content.form.field.calendar.now=Now +edit.content.form.field.calendar.clear=Clear edit.content.form.field.required=This field is mandatory edit.content.form.field.tags.placeholder=Search tags or type to create one. uniquefields.notification.duplicatevalues.title=Duplicate Unique Values