diff --git a/DemoData/Schema/Everything.json b/DemoData/Schema/Everything.json index 947864df..30263a57 100644 --- a/DemoData/Schema/Everything.json +++ b/DemoData/Schema/Everything.json @@ -40,6 +40,18 @@ "minimum": 42, "maximum": 100 }, + "date/date": { + "type": "date" + }, + "date/date (required)": { + "type": "date", + "required": true + }, + "date/date (w bounds)": { + "type": "date", + "minimum": "2020-01-01", + "maximum": "2030-12-31" + }, "select/select": { "type": "select", "options": [ diff --git a/extension.json b/extension.json index d8eee63c..fe12aafb 100644 --- a/extension.json +++ b/extension.json @@ -290,6 +290,7 @@ "cdxIconEye", "cdxIconEyeClosed", "cdxIconClock", + "cdxIconCalendar", "cdxIconCollapse", "cdxIconExpand", "cdxIconPushPin", @@ -306,6 +307,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", @@ -317,6 +319,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 0027281c..925412b0 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 c552f396..6079a599 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -142,6 +142,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