Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@
"cdxIconEye",
"cdxIconEyeClosed",
"cdxIconClock",
"cdxIconCalendar",
"cdxIconCollapse",
"cdxIconExpand",
"cdxIconPushPin",
Expand All @@ -282,6 +283,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",
Expand All @@ -293,6 +295,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",
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",

Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions resources/ext.neowiki/src/Neo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 13 additions & 1 deletion resources/ext.neowiki/src/NeoWikiExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<template>
<div class="date-attributes cdx-field">
<NeoNestedField :optional="true">
<template #label>
{{ $i18n( 'neowiki-property-editor-range' ).text() }}
</template>

<CdxField
class="date-attributes__minimum"
:status="minimumError === null ? 'default' : 'error'"
:messages="minimumError === null ? {} : { error: minimumError }"
>
<template #label>
{{ $i18n( 'neowiki-property-editor-minimum' ).text() }}
</template>

<CdxTextInput
input-type="date"
:model-value="minimumInput"
@update:model-value="updateMinimum"
/>
</CdxField>

<CdxField
class="date-attributes__maximum"
:status="maximumError === null ? 'default' : 'error'"
:messages="maximumError === null ? {} : { error: maximumError }"
>
<template #label>
{{ $i18n( 'neowiki-property-editor-maximum' ).text() }}
</template>

<CdxTextInput
input-type="date"
:model-value="maximumInput"
@update:model-value="updateMaximum"
/>
</CdxField>
</NeoNestedField>
</div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { CdxField, CdxTextInput } from '@wikimedia/codex';
import { DateProperty } from '@/domain/propertyTypes/Date.ts';
import { fromDateInputValue, toDateInputValue } from '@/domain/propertyTypes/dateConversion.ts';
import { AttributesEditorEmits, AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts';
import NeoNestedField from '@/components/common/NeoNestedField.vue';

const props = defineProps<AttributesEditorProps<DateProperty>>();
const emit = defineEmits<AttributesEditorEmits<DateProperty>>();

const minimumInput = ref( toDateInputValue( props.property.minimum ) );
const maximumInput = ref( toDateInputValue( props.property.maximum ) );
const minimumError = ref<string | null>( null );
const maximumError = ref<string | null>( null );

watch( () => props.property.minimum, ( newVal ) => {
minimumInput.value = toDateInputValue( newVal );
} );

watch( () => props.property.maximum, ( newVal ) => {
maximumInput.value = toDateInputValue( newVal );
} );

// `date` input wire values are always `YYYY-MM-DD`, so lexicographic ordering
// matches chronological ordering. The regex guards against malformed values
// bypassing the ordering check.
const DATE_WIRE_FORMAT = /^\d{4}-\d{2}-\d{2}$/;

function minExceedsMax( min: string, max: string ): boolean {
return DATE_WIRE_FORMAT.test( min ) &&
DATE_WIRE_FORMAT.test( max ) &&
min > max;
}

const updateMinimum = ( value: string ): void => {
minimumInput.value = value;

if ( minExceedsMax( value, maximumInput.value ) ) {
minimumError.value = mw.message( 'neowiki-property-editor-min-exceeds-max' ).text();
return;
}

minimumError.value = null;
maximumError.value = null;
emit( 'update:property', { minimum: fromDateInputValue( value ) } );
};

const updateMaximum = ( value: string ): void => {
maximumInput.value = value;

if ( minExceedsMax( minimumInput.value, value ) ) {
maximumError.value = mw.message( 'neowiki-property-editor-min-exceeds-max' ).text();
return;
}

maximumError.value = null;
minimumError.value = null;
emit( 'update:property', { maximum: fromDateInputValue( value ) } );
};
</script>
34 changes: 34 additions & 0 deletions resources/ext.neowiki/src/components/Value/DateDisplay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<time
v-if="parsedIso !== null"
:datetime="parsedIso"
>
{{ formattedValue }}
</time>
<span v-else>{{ rawValue }}</span>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { ValueType } from '@/domain/Value.ts';
import { DateProperty, formatDateForDisplay, parseStrictDate } from '@/domain/propertyTypes/Date.ts';
import { ValueDisplayProps } from '@/components/Value/ValueDisplayContract.ts';

const props = defineProps<ValueDisplayProps<DateProperty>>();

const rawValue = computed( (): string => {
if ( props.value.type !== ValueType.String ) {
return '';
}
return props.value.parts[ 0 ] ?? '';
} );

const parsedIso = computed( (): string | null => {
const raw = rawValue.value;
return raw !== '' && parseStrictDate( raw ) !== null ? raw : null;
} );

const formattedValue = computed( (): string => (
parsedIso.value === null ? '' : formatDateForDisplay( parsedIso.value )
) );
</script>
104 changes: 104 additions & 0 deletions resources/ext.neowiki/src/components/Value/DateInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<template>
<CdxField
:status="validationError === null ? 'default' : 'error'"
:messages="validationError === null ? {} : { error: validationError }"
:optional="props.property.required === false"
>
<template #label>
{{ label }}
<CdxIcon
v-if="props.property.description"
v-tooltip="props.property.description"
:icon="cdxIconInfo"
class="ext-neowiki-value-input__description-icon"
size="small"
/>
</template>
<CdxTextInput
input-type="date"
:start-icon="cdxIconCalendar"
:model-value="internalInputValue"
:min="toDateInputValue( props.property.minimum )"
:max="toDateInputValue( props.property.maximum )"
@update:model-value="onInput"
/>
</CdxField>
</template>

<script lang="ts">
import type { Value } from '@/domain/Value';
</script>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { CdxField, CdxIcon, CdxTextInput } from '@wikimedia/codex';
import { cdxIconInfo, cdxIconCalendar } from '@wikimedia/codex-icons';
import { newStringValue, StringValue, ValueType } from '@/domain/Value';
import { DateType, DateProperty, formatDateForDisplay } from '@/domain/propertyTypes/Date.ts';
import { fromDateInputValue, toDateInputValue } from '@/domain/propertyTypes/dateConversion.ts';
import { ValueInputEmits, ValueInputExposes, ValueInputProps } from '@/components/Value/ValueInputContract.ts';
import { NeoWikiServices } from '@/NeoWikiServices.ts';

const props = withDefaults(
defineProps<ValueInputProps<DateProperty>>(),
{
modelValue: undefined,
label: ''
}
);

const emit = defineEmits<ValueInputEmits>();

const validationError = ref<string | null>( null );
const internalInputValue = ref<string>( '' );

const initializeInputValue = ( value: Value | undefined ): void => {
if ( value && value.type === ValueType.String ) {
const str = ( value as StringValue ).parts[ 0 ];
internalInputValue.value = str ? toDateInputValue( str ) : '';
} else {
internalInputValue.value = '';
}
};

initializeInputValue( props.modelValue );

watch( () => props.modelValue, ( newValue ) => {
initializeInputValue( newValue );
validate( newValue && newValue.type === ValueType.String ? newValue as StringValue : undefined );
} );

const propertyType = NeoWikiServices.getPropertyTypeRegistry().getType( DateType.typeName );

function onInput( newValue: string ): void {
internalInputValue.value = newValue;
const isoValue = fromDateInputValue( newValue );
const value = isoValue !== undefined ? newStringValue( isoValue ) : undefined;
emit( 'update:modelValue', value );
validate( value );
}

function validate( value: StringValue | undefined ): void {
const errors = propertyType.validate( value, props.property );
if ( errors.length === 0 ) {
validationError.value = null;
return;
}
const error = errors[ 0 ];
const formattedArgs = ( ( error.args ?? [] ) as string[] ).map( formatDateForDisplay );
validationError.value = mw.message( `neowiki-field-${ error.code }`, ...formattedArgs ).text();
}

watch( () => props.property, () => {
validate( props.modelValue && props.modelValue.type === ValueType.String ? props.modelValue as StringValue : undefined );
} );

defineExpose<ValueInputExposes>( {
getCurrentValue: function(): Value | undefined {
const isoValue = fromDateInputValue( internalInputValue.value );
return isoValue !== undefined ? newStringValue( isoValue ) : undefined;
}
} );

validate( props.modelValue && props.modelValue.type === ValueType.String ? props.modelValue as StringValue : undefined );
</script>
Loading
Loading