From 68cd35f714672266468a83623728e05d9764623b Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 15 Sep 2026 12:07:23 -0400 Subject: [PATCH 1/4] feat(edit-content): fill the column, clear on the field, and rebuild the picker footer (#37465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date, Time and Date-and-time were the only fields in the new Edit Contentlet that behaved unlike their neighbours: narrower than their column, impossible to empty once set, and carrying a picker footer that offered a redundant Clear while hiding the timezone the value is read in. - Width: the control is block-level flex at full width; the input grows and the trigger stays flush right, with the focus ring and the invalid border still enclosing both as one unit. - Clearing: showClear is unconditional, so every type can be emptied, not just the expire-date field. PrimeNG's default clear icon is a bare with a click handler — unfocusable and unnamed — so a real
+ @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..5d8d6d27c8ff --- /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,481 @@ +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(); + }); + + // T021 — FR-008a. Absent, not empty: an empty element would still occupy the slot and + // shift the footer's layout. + it.each([...ALL_TYPES])( + 'should render no timezone element at all on a %s field when the timezone is unavailable', + async (fieldType) => { + spectator = buildHost(fieldType, null, { utcTimezone: null }); + 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 From f297fdba7c0dffc7294b087eaf30093988071b38 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 15 Sep 2026 14:33:40 -0400 Subject: [PATCH 2/4] fix(edit-content): fix Today/Now offset with no timezone, truncate long labels (#37465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on PR #37542 surfaced a real bug: getCurrentServerTime(null) returned UTC clock components reinterpreted as local, while every other timezone-less path in this field (convertServerTimeToUtc, convertUtcToServerTime) treats a missing zone as "use the browser's local clock as-is". The mismatch meant Today/Now displayed one time and stored another, off by the browser's UTC offset — permanently, if the timezone request fails outright rather than merely arriving late. Falls back to the browser's own local clock instead, matching the convention already used elsewhere. No change to the conversion helpers themselves, so FR-017's storage guarantees are untouched. Also: the footer's timezone text now truncates (min-w-0 + truncate, with a title attribute carrying the full label) rather than being able to push the Today/Now button out of the overlay on a very long zone name. Co-Authored-By: Claude Sonnet 5 --- .../calendar-field.component.html | 12 +++++- .../calendar-field.component.spec.ts | 38 +++++++++++++++++++ .../calendar-field/calendar-field.util.ts | 21 +++++++++- 3 files changed, 67 insertions(+), 4 deletions(-) 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 384ae4562a2b..42c6e6ec357b 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 @@ -38,9 +38,17 @@ descendant of the host. Tailwind's classes are global, so they apply wherever PrimeNG moves these nodes. --> -
+
@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.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.spec.ts index 5d8d6d27c8ff..43b98649667f 100644 --- 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 @@ -439,6 +439,44 @@ describe('DotCalendarFieldComponent', () => { expect(controlValue()).not.toBeNull(); }); + + // FR-013a — the timezone is loaded asynchronously and its request can fail outright, so + // this state is reachable and, on failure, permanent. Every other tz-less path in this + // field reads and writes wall-clock as local; the shortcut must not speak a different + // dialect, or the value it sets comes back displaying a different time than it set. + it('should store the true instant, and round-trip, when the timezone is unavailable', async () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, null, { utcTimezone: null }); + openPicker(); + + spectator.click(queryActionButton() as HTMLElement); + await settle(); + + const stored = controlValue() as number; + const shown = spectator.component.internalFormControl.value as Date; + + // The stored value is the real moment, not a wall-clock reinterpreted into another zone. + expect(stored).toBe(FAKE_NOW_UTC.getTime()); + + // And what the author sees is what that instant reads back as, so a reload is stable. + expect(shown.getHours()).toBe(new Date(stored).getHours()); + expect(shown.getMinutes()).toBe(new Date(stored).getMinutes()); + }); + + // FR-015b — symmetric with FR-007a for the clear control. p-button renders a native + // button, but nothing pinned that, and an icon-only or div-based footer action would + // satisfy every other criterion while being unreachable without a mouse. + it('should expose the footer action as a focusable button with an accessible name', () => { + spectator = buildHost(FIELD_TYPES.DATE_AND_TIME); + openPicker(); + + const btn = queryActionButton() as HTMLButtonElement; + + expect(btn.tagName).toBe('BUTTON'); + btn.focus(); + expect(document.activeElement).toBe(btn); + expect(btn.textContent?.trim()).toBeTruthy(); + }); + // T036 — FR-014 it('should mark the control touched and dirty after using the footer action', async () => { spectator = buildHost(FIELD_TYPES.DATE_AND_TIME); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts index da11398c625d..8962f7c505c4 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts @@ -319,8 +319,25 @@ export const createUtcDateAtMidnight = (year: number, month: number, date: numbe export const getCurrentServerTime = (systemTimezone: DotSystemTimezone | null): Date => { const now = new Date(); - // For UTC server or no timezone, show UTC time components as local time - if (!systemTimezone?.id || systemTimezone.id === 'UTC') { + // Timezone not loaded, or its request failed: there is no server zone to resolve against. + // Fall back to the browser's own clock, which is the convention every other timezone-less + // path in this field already follows — `convertServerTimeToUtc` and `convertUtcToServerTime` + // both return the date untouched when the zone is unknown, so wall-clock means local there. + // Returning UTC components here instead would make the value display one time and store + // another, off by the browser's offset, and read back differently than it was set. + if (!systemTimezone?.id) { + return new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours(), + now.getMinutes(), + now.getSeconds() + ); + } + + // Server explicitly on UTC: show UTC time components as local time + if (systemTimezone.id === 'UTC') { // Create a date showing UTC components as if they were local // This shows 18:26 UTC as 18:26 in the calendar return new Date( From d9e4f46f4bb8b5dc58d8742bcccf22e0734a92e6 Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 15 Sep 2026 18:18:47 -0400 Subject: [PATCH 3/4] fix(edit-content): align Today/Now and the footer label with the #37465 spec - Today/Now no longer falls back to the browser's clock when the system timezone is missing. getCurrentServerTime is restored to its previous implementation, so the button always goes through the existing getCurrentServerTime / convertServerTimeToUtc path, as the issue's acceptance criterion requires. The test asserting the fallback is removed. - Today/Now no longer closes the picker on Date-only fields. The field sets hideOnDateTimeSelect to false for all three types, so selecting a day already keeps the picker open; the shortcut now behaves the same. The test covers all three types. - The footer timezone label drops its truncation and tooltip; the label is always short enough for the picker. - Removes the test for the timezone-unavailable footer state, which the spec no longer describes. The @if guard stays: it prevents reading .label on null and implements the Date-only rule (FR-008a). Co-Authored-By: Claude Opus 5 --- .../calendar-field.component.html | 10 +--- .../calendar-field.component.spec.ts | 49 ++----------------- .../calendar-field.component.ts | 12 ++--- .../calendar-field/calendar-field.util.ts | 21 +------- 4 files changed, 10 insertions(+), 82 deletions(-) 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 42c6e6ec357b..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 @@ -40,15 +40,7 @@ -->
@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.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.component.spec.ts index 43b98649667f..b77eb9807884 100644 --- 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 @@ -320,18 +320,6 @@ describe('DotCalendarFieldComponent', () => { expect(queryInOverlay('[data-testid="calendar-field-timezone"]')).toBeNull(); }); - // T021 — FR-008a. Absent, not empty: an empty element would still occupy the slot and - // shift the footer's layout. - it.each([...ALL_TYPES])( - 'should render no timezone element at all on a %s field when the timezone is unavailable', - async (fieldType) => { - spectator = buildHost(fieldType, null, { utcTimezone: null }); - 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(); @@ -440,28 +428,6 @@ describe('DotCalendarFieldComponent', () => { }); - // FR-013a — the timezone is loaded asynchronously and its request can fail outright, so - // this state is reachable and, on failure, permanent. Every other tz-less path in this - // field reads and writes wall-clock as local; the shortcut must not speak a different - // dialect, or the value it sets comes back displaying a different time than it set. - it('should store the true instant, and round-trip, when the timezone is unavailable', async () => { - spectator = buildHost(FIELD_TYPES.DATE_AND_TIME, null, { utcTimezone: null }); - openPicker(); - - spectator.click(queryActionButton() as HTMLElement); - await settle(); - - const stored = controlValue() as number; - const shown = spectator.component.internalFormControl.value as Date; - - // The stored value is the real moment, not a wall-clock reinterpreted into another zone. - expect(stored).toBe(FAKE_NOW_UTC.getTime()); - - // And what the author sees is what that instant reads back as, so a reload is stable. - expect(shown.getHours()).toBe(new Date(stored).getHours()); - expect(shown.getMinutes()).toBe(new Date(stored).getMinutes()); - }); - // FR-015b — symmetric with FR-007a for the clear control. p-button renders a native // button, but nothing pinned that, and an icon-only or div-based footer action would // satisfy every other criterion while being unreachable without a mouse. @@ -493,17 +459,10 @@ describe('DotCalendarFieldComponent', () => { // T037 — FR-014a. A date-only pick is complete, so it closes; the two types carrying a // time stay open so the hour can still be adjusted. - it('should close the picker after Today on a Date-only field', async () => { - spectator = buildHost(FIELD_TYPES.DATE); - openPicker(); - - spectator.click(queryActionButton() as HTMLElement); - await settle(); - - expect(spectator.query(DatePicker).overlayVisible).toBe(false); - }); - - it.each([FIELD_TYPES.DATE_AND_TIME, FIELD_TYPES.TIME])( + // The footer action never closes the picker, matching a day click: hideOnDateTimeSelect + // is false for all three types, so selecting a value keeps the overlay open until the + // author clicks outside it. + it.each([...ALL_TYPES])( 'should leave the picker open after the footer action on a %s field', async (fieldType) => { spectator = buildHost(fieldType); 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 50e8a751e173..7b97c823ba16 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 @@ -163,9 +163,9 @@ export class DotCalendarFieldComponent extends BaseControlValueAccessor((isDisabled) => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts index 8962f7c505c4..da11398c625d 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/components/calendar-field/calendar-field.util.ts @@ -319,25 +319,8 @@ export const createUtcDateAtMidnight = (year: number, month: number, date: numbe export const getCurrentServerTime = (systemTimezone: DotSystemTimezone | null): Date => { const now = new Date(); - // Timezone not loaded, or its request failed: there is no server zone to resolve against. - // Fall back to the browser's own clock, which is the convention every other timezone-less - // path in this field already follows — `convertServerTimeToUtc` and `convertUtcToServerTime` - // both return the date untouched when the zone is unknown, so wall-clock means local there. - // Returning UTC components here instead would make the value display one time and store - // another, off by the browser's offset, and read back differently than it was set. - if (!systemTimezone?.id) { - return new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - now.getHours(), - now.getMinutes(), - now.getSeconds() - ); - } - - // Server explicitly on UTC: show UTC time components as local time - if (systemTimezone.id === 'UTC') { + // For UTC server or no timezone, show UTC time components as local time + if (!systemTimezone?.id || systemTimezone.id === 'UTC') { // Create a date showing UTC components as if they were local // This shows 18:26 UTC as 18:26 in the calendar return new Date( From a34a8a21805e73eaed0500eb02c9cbd6e8eb9eff Mon Sep 17 00:00:00 2001 From: Adrian Molina Date: Tue, 15 Sep 2026 23:16:02 -0400 Subject: [PATCH 4/4] style(edit-content): satisfy nx format:check on the calendar field (#37465) CI's "Frontend Unit Tests" job failed on its format-test goal, which runs nx format:check. The tests themselves passed; two files were unformatted: - calendar-field.component.spec.ts: two stray blank lines. - dot-edit-content-calendar-field.component.html: Tailwind class order, which prettier-plugin-tailwindcss sorts. Both came in through edits whose commits staged a different file set, so lint-staged's format:write never saw them. Co-Authored-By: Claude Opus 5 --- .../calendar-field/calendar-field.component.spec.ts | 2 -- .../dot-edit-content-calendar-field.component.html | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) 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 index b77eb9807884..a842dbd918e3 100644 --- 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 @@ -240,7 +240,6 @@ describe('DotCalendarFieldComponent', () => { 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 @@ -427,7 +426,6 @@ describe('DotCalendarFieldComponent', () => { expect(controlValue()).not.toBeNull(); }); - // FR-015b — symmetric with FR-007a for the clear control. p-button renders a native // button, but nothing pinned that, and an icon-only or div-based footer action would // satisfy every other criterion while being unreachable without a mouse. diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.html index 5ae95d86b8e9..c04324300712 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component.html @@ -19,13 +19,13 @@ @if ((fieldHasError && isRequired) || hasHint) { -
+
-
+