From cc05903362f3badcaef38bc63518ba8711b3b5bb Mon Sep 17 00:00:00 2001 From: morne-tools Date: Sun, 17 May 2026 01:31:04 +0200 Subject: [PATCH 1/2] Add Date property type Fixes https://github.com/ProfessionalWiki/NeoWiki/issues/788 Adds a calendar `date` property type (a date with no time or timezone component), parallel to the existing `dateTime` type (#678) and distinct from the planned EDTF type (#679). Values are strict ISO 8601 calendar dates in `YYYY-MM-DD` form. Values carrying a time/timezone component, partial values (year-only, year-month), and calendar overflows (e.g. `2025-02-30`, Feb 29 in a non-leap year) are rejected. Optional inclusive `minimum`/`maximum` bounds follow the same shape rules. Backend: - `DateType` property type and `DateProperty` schema definition, mirroring the DateTime equivalents with a date-only regex plus a `checkdate` calendar-overflow guard. - Registered in `PropertyTypeRegistry::withCoreTypes()` and as a Neo4j scalar builder in `Neo4jValueBuilderRegistry::withCoreBuilders()`. Frontend: - `Date.ts` (validation, strict parsing, locale display formatting), `dateConversion.ts` (parse-guarded `` wire conversion), and `DateInput` / `DateDisplay` / `DateAttributesEditor` Vue components. - Registered in `Neo.ts`, `NeoWikiExtension.ts` (calendar icon) and exported from `public-api.ts`. i18n: `neowiki-property-type-date` label and `neowiki-field-invalid-date` validation message, with qqq documentation and extension.json message registration. Tests: PHP `DateTypeTest`, `DatePropertyTest`, and a `date` builder assertion in `Neo4jValueBuilderRegistryTest`; TS specs for the domain type, conversion helpers, and all three Vue components. Verification: full PHP unit suite for the new/changed files, phpcs, and phpstan level 9 all pass; full JS suite (863 tests, incl. 81 new) passes with eslint and vue-tsc clean. Browser verification is blocked: the running dev stack is bind-mounted to a separate checkout and the worktree cannot be deployed to it without intrusively rebuilding that checkout's assets; the new Vue components are instead covered by component-level tests asserting the rendered DOM and behavior. Identified with Claude Code Co-Authored-By: Claude Opus 4.7 --- extension.json | 2 + i18n/en.json | 2 + i18n/qqq.json | 2 + resources/ext.neowiki/src/Neo.ts | 2 + resources/ext.neowiki/src/NeoWikiExtension.ts | 14 +- .../Property/DateAttributesEditor.vue | 103 ++++++++ .../src/components/Value/DateDisplay.vue | 34 +++ .../src/components/Value/DateInput.vue | 104 ++++++++ .../src/domain/propertyTypes/Date.ts | 178 +++++++++++++ .../domain/propertyTypes/dateConversion.ts | 29 ++ resources/ext.neowiki/src/public-api.ts | 5 + .../Property/DateAttributesEditor.spec.ts | 188 +++++++++++++ .../components/Value/DateDisplay.spec.ts | 86 ++++++ .../tests/components/Value/DateInput.spec.ts | 190 ++++++++++++++ .../tests/domain/propertyTypes/Date.spec.ts | 248 ++++++++++++++++++ .../propertyTypes/dateConversion.spec.ts | 56 ++++ .../PropertyType/PropertyTypeRegistry.php | 2 + src/Domain/PropertyType/Types/DateType.php | 32 +++ src/Domain/Schema/Property/DateProperty.php | 93 +++++++ .../Neo4j/Neo4jValueBuilderRegistry.php | 1 + .../PropertyType/Types/DateTypeTest.php | 19 ++ .../Schema/Property/DatePropertyTest.php | 154 +++++++++++ .../Neo4j/Neo4jValueBuilderRegistryTest.php | 1 + 23 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 resources/ext.neowiki/src/components/SchemaEditor/Property/DateAttributesEditor.vue create mode 100644 resources/ext.neowiki/src/components/Value/DateDisplay.vue create mode 100644 resources/ext.neowiki/src/components/Value/DateInput.vue create mode 100644 resources/ext.neowiki/src/domain/propertyTypes/Date.ts create mode 100644 resources/ext.neowiki/src/domain/propertyTypes/dateConversion.ts create mode 100644 resources/ext.neowiki/tests/components/SchemaEditor/Property/DateAttributesEditor.spec.ts create mode 100644 resources/ext.neowiki/tests/components/Value/DateDisplay.spec.ts create mode 100644 resources/ext.neowiki/tests/components/Value/DateInput.spec.ts create mode 100644 resources/ext.neowiki/tests/domain/propertyTypes/Date.spec.ts create mode 100644 resources/ext.neowiki/tests/domain/propertyTypes/dateConversion.spec.ts create mode 100644 src/Domain/PropertyType/Types/DateType.php create mode 100644 src/Domain/Schema/Property/DateProperty.php create mode 100644 tests/phpunit/Domain/PropertyType/Types/DateTypeTest.php create mode 100644 tests/phpunit/Domain/Schema/Property/DatePropertyTest.php diff --git a/extension.json b/extension.json index 6791e678..6798a714 100644 --- a/extension.json +++ b/extension.json @@ -282,6 +282,7 @@ "neowiki-property-type-relation", "neowiki-property-type-select", "neowiki-property-type-datetime", + "neowiki-property-type-date", "neowiki-infobox-type", "neowiki-infobox-edit-link", "neowiki-field-required", @@ -293,6 +294,7 @@ "neowiki-field-max-value", "neowiki-field-invalid-url", "neowiki-field-invalid-datetime", + "neowiki-field-invalid-date", "neowiki-field-invalid-option", "neowiki-field-single-value-only", "neowiki-select-placeholder", diff --git a/i18n/en.json b/i18n/en.json index 213be918..5b1c9e0f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -19,6 +19,7 @@ "neowiki-property-type-relation": "Relation", "neowiki-property-type-select": "Select", "neowiki-property-type-datetime": "Date & Time", + "neowiki-property-type-date": "Date", "neowiki-neojson-description": "Editing neo slot of \"$1\"", @@ -59,6 +60,7 @@ "neowiki-field-max-value": "Maximum value is $1.", "neowiki-field-invalid-url": "Please enter a valid URL.", "neowiki-field-invalid-datetime": "Please enter a valid date and time.", + "neowiki-field-invalid-date": "Please enter a valid date.", "neowiki-field-invalid-option": "\"$1\" is not a valid option.", "neowiki-field-single-value-only": "Only one value can be selected.", "neowiki-select-placeholder": "Select an option", diff --git a/i18n/qqq.json b/i18n/qqq.json index 63619fc3..41ecb3ad 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -148,6 +148,8 @@ "neowiki-select-no-results": "Message shown in the multi-select dropdown when the typed text does not match any available option.", "neowiki-field-invalid-datetime": "Validation error shown when the entered date and time value cannot be parsed. DateTime values must be strict ISO 8601 / xsd:dateTime strings with an explicit timezone offset or 'Z' (for example, '2025-06-15T12:00:00Z' or '2025-06-15T12:00:00+02:00'). Partial values such as year-only or date-only are rejected, and min/max bounds are inclusive.", "neowiki-property-type-datetime": "Label for the date and time property type in the schema editor", + "neowiki-field-invalid-date": "Validation error shown when the entered date value cannot be parsed. Date values must be strict ISO 8601 calendar dates in YYYY-MM-DD form with no time or timezone component (for example, '2025-06-15'). Values with a time part, partial values such as year-only, and calendar overflows such as '2025-02-30' are rejected, and min/max bounds are inclusive.", + "neowiki-property-type-date": "Label for the date (without time) property type in the schema editor", "neowiki-select-unknown-option": "Placeholder shown in a select-property display when a stored option ID cannot be resolved to a label (e.g. the option was removed from the Schema).", "neowiki-managesubjects-tab": "Label for the Data tab that appears alongside the standard action tabs (View, Edit, History) on content pages, linking to the Subject management page.", diff --git a/resources/ext.neowiki/src/Neo.ts b/resources/ext.neowiki/src/Neo.ts index 4b39a35c..68b0ffce 100644 --- a/resources/ext.neowiki/src/Neo.ts +++ b/resources/ext.neowiki/src/Neo.ts @@ -4,6 +4,7 @@ import { SelectType } from '@/domain/propertyTypes/Select'; import { RelationType } from '@/domain/propertyTypes/Relation'; import { UrlType } from '@/domain/propertyTypes/Url'; import { DateTimeType } from '@/domain/propertyTypes/DateTime'; +import { DateType } from '@/domain/propertyTypes/Date'; import { PropertyTypeRegistry } from '@/domain/PropertyType'; import { PropertyDefinitionDeserializer } from '@/domain/PropertyDefinition'; import { ValueDeserializer } from '@/persistence/ValueDeserializer'; @@ -35,6 +36,7 @@ export class Neo { registry.registerType( new RelationType() ); registry.registerType( new UrlType() ); registry.registerType( new DateTimeType() ); + registry.registerType( new DateType() ); return registry; } diff --git a/resources/ext.neowiki/src/NeoWikiExtension.ts b/resources/ext.neowiki/src/NeoWikiExtension.ts index 18381134..9e7c42ab 100644 --- a/resources/ext.neowiki/src/NeoWikiExtension.ts +++ b/resources/ext.neowiki/src/NeoWikiExtension.ts @@ -11,12 +11,15 @@ import { SelectType } from '@/domain/propertyTypes/Select.ts'; import SelectDisplay from '@/components/Value/SelectDisplay.vue'; import { RelationType } from '@/domain/propertyTypes/Relation.ts'; import { DateTimeType } from '@/domain/propertyTypes/DateTime.ts'; +import { DateType } from '@/domain/propertyTypes/Date.ts'; import { TypeSpecificComponentRegistry } from '@/TypeSpecificComponentRegistry.ts'; import { ViewTypeRegistry } from '@/ViewTypeRegistry.ts'; import Infobox from '@/components/Views/Infobox.vue'; import RelationDisplay from '@/components/Value/RelationDisplay.vue'; import DateTimeDisplay from '@/components/Value/DateTimeDisplay.vue'; import DateTimeInput from '@/components/Value/DateTimeInput.vue'; +import DateDisplay from '@/components/Value/DateDisplay.vue'; +import DateInput from '@/components/Value/DateInput.vue'; import { HttpClient } from '@/infrastructure/HttpClient/HttpClient'; import { ProductionHttpClient } from '@/infrastructure/HttpClient/ProductionHttpClient'; import { RestSchemaRepository } from '@/persistence/RestSchemaRepository.ts'; @@ -45,13 +48,14 @@ import { MediaWikiPageSaver } from '@/persistence/MediaWikiPageSaver.ts'; import { SubjectDeserializer } from '@/persistence/SubjectDeserializer.ts'; import { Neo } from '@/Neo.ts'; // import { cdxIconStringInteger } from '@/assets/CustomIcons.ts'; -import { cdxIconLink, cdxIconSearchCaseSensitive, cdxIconArticles, cdxIconListBullet, cdxIconMathematics, cdxIconClock } from '@wikimedia/codex-icons'; +import { cdxIconLink, cdxIconSearchCaseSensitive, cdxIconArticles, cdxIconListBullet, cdxIconMathematics, cdxIconClock, cdxIconCalendar } from '@wikimedia/codex-icons'; import TextAttributesEditor from '@/components/SchemaEditor/Property/TextAttributesEditor.vue'; import NumberAttributesEditor from '@/components/SchemaEditor/Property/NumberAttributesEditor.vue'; import SelectAttributesEditor from '@/components/SchemaEditor/Property/SelectAttributesEditor.vue'; import UrlAttributesEditor from '@/components/SchemaEditor/Property/UrlAttributesEditor.vue'; import RelationAttributesEditor from '@/components/SchemaEditor/Property/RelationAttributesEditor.vue'; import DateTimeAttributesEditor from '@/components/SchemaEditor/Property/DateTimeAttributesEditor.vue'; +import DateAttributesEditor from '@/components/SchemaEditor/Property/DateAttributesEditor.vue'; import { SubjectValidator } from '@/domain/SubjectValidator.ts'; import { PropertyTypeRegistry } from '@/domain/PropertyType.ts'; import { StoreStateLoader } from '@/persistence/StoreStateLoader.ts'; @@ -126,6 +130,14 @@ export class NeoWikiExtension { icon: cdxIconClock, } ); + registry.registerType( DateType.typeName, { + valueDisplayComponent: DateDisplay, + valueEditor: DateInput, + attributesEditor: DateAttributesEditor, + label: 'neowiki-property-type-date', + icon: cdxIconCalendar, + } ); + return registry; } diff --git a/resources/ext.neowiki/src/components/SchemaEditor/Property/DateAttributesEditor.vue b/resources/ext.neowiki/src/components/SchemaEditor/Property/DateAttributesEditor.vue new file mode 100644 index 00000000..46b74a4f --- /dev/null +++ b/resources/ext.neowiki/src/components/SchemaEditor/Property/DateAttributesEditor.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/ext.neowiki/src/components/Value/DateDisplay.vue b/resources/ext.neowiki/src/components/Value/DateDisplay.vue new file mode 100644 index 00000000..d7651388 --- /dev/null +++ b/resources/ext.neowiki/src/components/Value/DateDisplay.vue @@ -0,0 +1,34 @@ + + + diff --git a/resources/ext.neowiki/src/components/Value/DateInput.vue b/resources/ext.neowiki/src/components/Value/DateInput.vue new file mode 100644 index 00000000..57f95f17 --- /dev/null +++ b/resources/ext.neowiki/src/components/Value/DateInput.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/resources/ext.neowiki/src/domain/propertyTypes/Date.ts b/resources/ext.neowiki/src/domain/propertyTypes/Date.ts new file mode 100644 index 00000000..c2fe04a3 --- /dev/null +++ b/resources/ext.neowiki/src/domain/propertyTypes/Date.ts @@ -0,0 +1,178 @@ +import type { PropertyDefinition } from '@/domain/PropertyDefinition'; +import { PropertyName } from '@/domain/PropertyDefinition'; +import { newStringValue, type StringValue, ValueType } from '@/domain/Value'; +import { BasePropertyType, ValueValidationError } from '@/domain/PropertyType'; + +export interface DateProperty extends PropertyDefinition { + + /** + * Inclusive lower bound. Must be a strict ISO 8601 calendar date in + * `YYYY-MM-DD` form with no time or timezone component (e.g. `2025-06-15`). + */ + readonly minimum?: string; + + /** + * Inclusive upper bound. Same shape rules as the minimum. + */ + readonly maximum?: string; + +} + +/** + * Matches xsd:date-like strings: a calendar date with no time or timezone + * component. A subsequent calendar-overflow check is used to reject inputs + * like `2025-02-30` that the regex alone cannot detect. + */ +const ISO_DATE_REGEX = /^(-?\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; + +/** + * Parses a strict ISO 8601 calendar date (`YYYY-MM-DD`). Returns a millisecond + * timestamp at UTC midnight, or `null` if the value is malformed, carries a + * time/timezone component, or is a calendar overflow (e.g. Feb 30) that `Date` + * would silently roll over. + * + * The timestamp is only used for chronological ordering against the min/max + * bounds; it is anchored to UTC so the comparison is independent of the host + * timezone. + */ +export function parseStrictDate( value: string ): number | null { + const match = ISO_DATE_REGEX.exec( value ); + if ( match === null ) { + return null; + } + + const timestamp = Date.parse( `${ value }T00:00:00Z` ); + if ( isNaN( timestamp ) ) { + return null; + } + + // Reject calendar overflows (e.g. Feb 30) that Date silently rolls over. + // Compare the declared year/month/day against the parsed UTC date. + const utc = new Date( timestamp ); + if ( + utc.getUTCFullYear() !== Number( match[ 1 ] ) || + utc.getUTCMonth() + 1 !== Number( match[ 2 ] ) || + utc.getUTCDate() !== Number( match[ 3 ] ) + ) { + return null; + } + + return timestamp; +} + +/** + * Property type for xsd:date-style calendar dates without a time component. + * + * Values must be strict ISO 8601 dates in `YYYY-MM-DD` form (e.g. + * `2025-06-15`). Values carrying a time or timezone component + * (`2025-06-15T00:00:00Z`), partial values such as year-only (`2025`) or + * year-month (`2025-06`), and calendar overflows like `2025-02-30` are + * rejected. The `minimum` and `maximum` bounds are inclusive and must + * themselves be well-formed ISO 8601 dates. + * + * If `minimum` or `maximum` on the passed-in property is itself malformed, + * that bound is silently ignored during validation (fail-open). The PHP + * persistence layer rejects malformed bounds at construction, so this only + * matters if something bypasses that path. + */ +export class DateType extends BasePropertyType { + + public static readonly valueType = ValueType.String; + + public static readonly typeName = 'date'; + + public getDisplayAttributeNames(): string[] { + return []; + } + + public getExampleValue(): StringValue { + return newStringValue( '2026-01-01' ); + } + + public createPropertyDefinitionFromJson( base: PropertyDefinition, json: any ): DateProperty { + return { + ...base, + minimum: json.minimum, + maximum: json.maximum, + } as DateProperty; + } + + public validate( value: StringValue | undefined, property: DateProperty ): ValueValidationError[] { + const errors: ValueValidationError[] = []; + + if ( property.required && value === undefined ) { + errors.push( { code: 'required' } ); + return errors; + } + + if ( value !== undefined && value.parts.length > 0 ) { + const timestamp = parseStrictDate( value.parts[ 0 ] ); + + if ( timestamp === null ) { + errors.push( { code: 'invalid-date' } ); + return errors; + } + + const minimum = property.minimum; + const minimumTimestamp = minimum !== undefined ? parseStrictDate( minimum ) : null; + if ( minimum !== undefined && minimumTimestamp !== null && timestamp < minimumTimestamp ) { + errors.push( { + code: 'min-value', + args: [ minimum ], + } ); + } + + const maximum = property.maximum; + const maximumTimestamp = maximum !== undefined ? parseStrictDate( maximum ) : null; + if ( maximum !== undefined && maximumTimestamp !== null && timestamp > maximumTimestamp ) { + errors.push( { + code: 'max-value', + args: [ maximum ], + } ); + } + } + + return errors; + } + +} + +/** + * Formats a `YYYY-MM-DD` string as a human-readable date using the user's + * browser locale, with no time component. + * + * The date is interpreted in UTC and rendered with `timeZone: 'UTC'` so the + * displayed calendar day always matches the stored day regardless of the host + * timezone. Falls back to the raw input when it cannot be parsed, so malformed + * values surface verbatim in the UI rather than as `Invalid Date`. + */ +export function formatDateForDisplay( iso: string ): string { + if ( parseStrictDate( iso ) === null ) { + return iso; + } + + const date = new Date( `${ iso }T00:00:00Z` ); + + return date.toLocaleDateString( undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + } ); +} + +type DatePropertyAttributes = Omit, 'name'> & { + name?: string | PropertyName; +}; + +export function newDateProperty( attributes: DatePropertyAttributes = {} ): DateProperty { + return { + name: attributes.name instanceof PropertyName ? attributes.name : new PropertyName( attributes.name || 'Date' ), + type: DateType.typeName, + description: attributes.description ?? '', + required: attributes.required ?? false, + default: attributes.default, + minimum: attributes.minimum, + maximum: attributes.maximum, + }; +} diff --git a/resources/ext.neowiki/src/domain/propertyTypes/dateConversion.ts b/resources/ext.neowiki/src/domain/propertyTypes/dateConversion.ts new file mode 100644 index 00000000..47d9bbd6 --- /dev/null +++ b/resources/ext.neowiki/src/domain/propertyTypes/dateConversion.ts @@ -0,0 +1,29 @@ +/** + * Conversion between stored ISO 8601 date strings and the `` + * wire format. + * + * - Storage format is a calendar date `YYYY-MM-DD` (no time, no timezone). + * - The `date` input control's value is also `YYYY-MM-DD` (or empty). + * + * The two formats coincide, so conversion is an identity guarded by parsing: + * malformed or non-date values collapse to the empty/undefined sentinel rather + * than being passed through to the control or persisted. + */ + +import { parseStrictDate } from '@/domain/propertyTypes/Date'; + +export function toDateInputValue( iso: string | undefined ): string { + if ( iso === undefined || iso === '' ) { + return ''; + } + + return parseStrictDate( iso ) === null ? '' : iso; +} + +export function fromDateInputValue( local: string ): string | undefined { + if ( local === '' ) { + return undefined; + } + + return parseStrictDate( local ) === null ? undefined : local; +} diff --git a/resources/ext.neowiki/src/public-api.ts b/resources/ext.neowiki/src/public-api.ts index fbbc7816..43d75936 100644 --- a/resources/ext.neowiki/src/public-api.ts +++ b/resources/ext.neowiki/src/public-api.ts @@ -55,6 +55,8 @@ export * from './domain/SubjectWithContext'; export * from './domain/Value'; export * from './domain/propertyTypes/DateTime'; export * from './domain/propertyTypes/dateTimeConversion'; +export * from './domain/propertyTypes/Date'; +export * from './domain/propertyTypes/dateConversion'; export * from './domain/propertyTypes/Number'; export * from './domain/propertyTypes/Relation'; export * from './domain/propertyTypes/Select'; @@ -106,6 +108,7 @@ export { default as SchemaCreator } from './components/SchemaCreator/SchemaCreat export { default as SchemaDisplay } from './components/SchemaDisplay/SchemaDisplay.vue'; export { default as SchemaDisplayHeader } from './components/SchemaDisplay/SchemaDisplayHeader.vue'; export { default as DateTimeAttributesEditor } from './components/SchemaEditor/Property/DateTimeAttributesEditor.vue'; +export { default as DateAttributesEditor } from './components/SchemaEditor/Property/DateAttributesEditor.vue'; export { default as NumberAttributesEditor } from './components/SchemaEditor/Property/NumberAttributesEditor.vue'; export { default as RelationAttributesEditor } from './components/SchemaEditor/Property/RelationAttributesEditor.vue'; export { default as SelectAttributesEditor } from './components/SchemaEditor/Property/SelectAttributesEditor.vue'; @@ -126,6 +129,8 @@ export { default as SubjectStatementsView } from './components/SubjectsManager/S export { default as SubjectsManagerPage } from './components/SubjectsManager/SubjectsManagerPage.vue'; export { default as DateTimeDisplay } from './components/Value/DateTimeDisplay.vue'; export { default as DateTimeInput } from './components/Value/DateTimeInput.vue'; +export { default as DateDisplay } from './components/Value/DateDisplay.vue'; +export { default as DateInput } from './components/Value/DateInput.vue'; export { default as NumberDisplay } from './components/Value/NumberDisplay.vue'; export { default as NumberInput } from './components/Value/NumberInput.vue'; export { default as RelationDisplay } from './components/Value/RelationDisplay.vue'; diff --git a/resources/ext.neowiki/tests/components/SchemaEditor/Property/DateAttributesEditor.spec.ts b/resources/ext.neowiki/tests/components/SchemaEditor/Property/DateAttributesEditor.spec.ts new file mode 100644 index 00000000..b642d938 --- /dev/null +++ b/resources/ext.neowiki/tests/components/SchemaEditor/Property/DateAttributesEditor.spec.ts @@ -0,0 +1,188 @@ +import { DOMWrapper, VueWrapper } from '@vue/test-utils'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { CdxTextInput } from '@wikimedia/codex'; +import DateAttributesEditor from '@/components/SchemaEditor/Property/DateAttributesEditor.vue'; +import { newDateProperty, DateProperty } from '@/domain/propertyTypes/Date'; +import { AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts'; +import { createTestWrapper, FieldProps, setupMwMock } from '../../../VueTestHelpers.ts'; + +describe( 'DateAttributesEditor', () => { + beforeEach( () => { + setupMwMock( { + messages: { + 'neowiki-property-editor-min-exceeds-max': 'Minimum cannot exceed maximum.', + }, + functions: [ 'message' ], + } ); + } ); + + function newWrapper( props: Partial> = {} ): VueWrapper { + return createTestWrapper( DateAttributesEditor, { + property: newDateProperty( {} ), + ...props, + } ); + } + + function getInputs( wrapper: VueWrapper ): DOMWrapper[] { + return wrapper.findAll( 'input[type="date"]' ); + } + + function getMinimumFieldProps( wrapper: VueWrapper ): FieldProps { + return ( wrapper.findComponent( '.date-attributes__minimum' ) as VueWrapper ).props() as FieldProps; + } + + function getMaximumFieldProps( wrapper: VueWrapper ): FieldProps { + return ( wrapper.findComponent( '.date-attributes__maximum' ) as VueWrapper ).props() as FieldProps; + } + + describe( 'rendering', () => { + it( 'renders two CdxTextInput components with date input-type', () => { + const wrapper = newWrapper(); + + const textInputs = wrapper.findAllComponents( CdxTextInput ); + expect( textInputs.length ).toBe( 2 ); + expect( textInputs[ 0 ].props( 'inputType' ) ).toBe( 'date' ); + expect( textInputs[ 1 ].props( 'inputType' ) ).toBe( 'date' ); + } ); + } ); + + describe( 'displaying existing values', () => { + it( 'renders minimum and maximum as the stored date strings', () => { + const wrapper = newWrapper( { + property: newDateProperty( { minimum: '2020-01-01', maximum: '2030-12-31' } ), + } ); + const inputs = getInputs( wrapper ); + + expect( inputs[ 0 ].element.value ).toBe( '2020-01-01' ); + expect( inputs[ 1 ].element.value ).toBe( '2030-12-31' ); + } ); + + it( 'displays empty inputs when minimum and maximum are undefined', () => { + const wrapper = newWrapper(); + const inputs = getInputs( wrapper ); + + expect( inputs[ 0 ].element.value ).toBe( '' ); + expect( inputs[ 1 ].element.value ).toBe( '' ); + } ); + } ); + + describe( 'range validation', () => { + it( 'shows no error when both fields are empty', () => { + const wrapper = newWrapper(); + + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'default' ); + expect( getMaximumFieldProps( wrapper ).status ).toBe( 'default' ); + } ); + + it( 'shows error on min field when min exceeds max', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { maximum: '2020-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 0 ].setValue( '2030-01-01' ); + + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'error' ); + expect( getMinimumFieldProps( wrapper ).messages ).toEqual( { + error: 'Minimum cannot exceed maximum.', + } ); + expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); + } ); + + it( 'shows error on max field when max is less than min', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { minimum: '2030-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 1 ].setValue( '2020-01-01' ); + + expect( getMaximumFieldProps( wrapper ).status ).toBe( 'error' ); + expect( getMaximumFieldProps( wrapper ).messages ).toEqual( { + error: 'Minimum cannot exceed maximum.', + } ); + expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); + } ); + + it( 'allows min equal to max', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { maximum: '2020-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 0 ].setValue( '2020-01-01' ); + + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'default' ); + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { minimum: '2020-01-01' } ] ); + } ); + + it( 'clears min error when valid value resolves conflict', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { maximum: '2020-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 0 ].setValue( '2030-01-01' ); + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'error' ); + + await inputs[ 0 ].setValue( '2010-01-01' ); + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'default' ); + } ); + + it( 'clears max error when valid min resolves conflict', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { minimum: '2030-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 1 ].setValue( '2020-01-01' ); + expect( getMaximumFieldProps( wrapper ).status ).toBe( 'error' ); + + await inputs[ 0 ].setValue( '2010-01-01' ); + expect( getMinimumFieldProps( wrapper ).status ).toBe( 'default' ); + expect( getMaximumFieldProps( wrapper ).status ).toBe( 'default' ); + } ); + } ); + + describe( 'emitting updates', () => { + it( 'emits minimum as the typed date string', async () => { + const wrapper = newWrapper(); + const inputs = getInputs( wrapper ); + + await inputs[ 0 ].setValue( '2020-01-01' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { minimum: '2020-01-01' } ] ); + } ); + + it( 'emits maximum as the typed date string', async () => { + const wrapper = newWrapper(); + const inputs = getInputs( wrapper ); + + await inputs[ 1 ].setValue( '2030-12-31' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { maximum: '2030-12-31' } ] ); + } ); + + it( 'emits undefined minimum when the min input is cleared', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { minimum: '2020-01-01' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 0 ].setValue( '' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { minimum: undefined } ] ); + } ); + + it( 'emits undefined maximum when the max input is cleared', async () => { + const wrapper = newWrapper( { + property: newDateProperty( { maximum: '2030-12-31' } ), + } ); + const inputs = getInputs( wrapper ); + + await inputs[ 1 ].setValue( '' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { maximum: undefined } ] ); + } ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/components/Value/DateDisplay.spec.ts b/resources/ext.neowiki/tests/components/Value/DateDisplay.spec.ts new file mode 100644 index 00000000..f480ba79 --- /dev/null +++ b/resources/ext.neowiki/tests/components/Value/DateDisplay.spec.ts @@ -0,0 +1,86 @@ +import { VueWrapper } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import DateDisplay from '@/components/Value/DateDisplay.vue'; +import { newNumberValue, newStringValue, Value } from '@/domain/Value'; +import { newDateProperty, DateProperty } from '@/domain/propertyTypes/Date'; +import { ValueDisplayProps } from '@/components/Value/ValueDisplayContract.ts'; +import { createTestWrapper } from '../../VueTestHelpers.ts'; + +function createWrapper( props: Partial> ): VueWrapper { + const defaultProps: ValueDisplayProps = { + value: newStringValue( '' ), + property: newDateProperty(), + }; + + return createTestWrapper( DateDisplay, { + ...defaultProps, + ...props, + } ); +} + +function createWrapperWithValue( value: Value ): VueWrapper { + return createWrapper( { value } ); +} + +describe( 'DateDisplay', () => { + describe( 'valid ISO 8601 date input', () => { + it( 'renders a