Skip to content
Merged
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
6 changes: 6 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@
"neowiki-property-editor-maximum",
"neowiki-property-editor-precision",
"neowiki-property-editor-precision-non-negative",
"neowiki-property-editor-length-constraint",
"neowiki-property-editor-length-between",
"neowiki-property-editor-length-and",
"neowiki-property-editor-length-characters",
"neowiki-property-editor-length-whole-number",
"neowiki-property-editor-length-min-exceeds-max",
"neowiki-property-editor-multiple",
"neowiki-property-editor-unique-items",
"neowiki-save-schema",
Expand Down
6 changes: 6 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@
"neowiki-property-editor-maximum": "Maximum",
"neowiki-property-editor-precision": "Precision",
"neowiki-property-editor-precision-non-negative": "Precision cannot be negative.",
"neowiki-property-editor-length-constraint": "Length constraint",
"neowiki-property-editor-length-between": "between",
"neowiki-property-editor-length-and": "and",
"neowiki-property-editor-length-characters": "characters",
"neowiki-property-editor-length-whole-number": "Must be a whole number of at least 1.",
"neowiki-property-editor-length-min-exceeds-max": "Minimum cannot exceed maximum.",
"neowiki-property-editor-multiple": "Allow multiple values",
"neowiki-property-editor-unique-items": "Values must be unique",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,109 @@
{{ $i18n( 'neowiki-property-editor-unique-items' ).text() }}
</CdxToggleSwitch>
</CdxField>

<CdxField
:status="lengthError === null ? 'default' : 'error'"
:messages="lengthError === null ? {} : { error: lengthError }"
>
<template #label>
{{ $i18n( 'neowiki-property-editor-length-constraint' ).text() }}
</template>
<div class="text-attributes__length-constraint">
<span>{{ $i18n( 'neowiki-property-editor-length-between' ).text() }}</span>
<CdxTextInput
:model-value="minLengthInput"
input-type="number"
min="1"
class="text-attributes__length-input"
@update:model-value="updateMinLength"
/>
<span>{{ $i18n( 'neowiki-property-editor-length-and' ).text() }}</span>
<CdxTextInput
:model-value="maxLengthInput"
input-type="number"
min="1"
class="text-attributes__length-input"
@update:model-value="updateMaxLength"
/>
<span>{{ $i18n( 'neowiki-property-editor-length-characters' ).text() }}</span>
</div>
</CdxField>
</div>
</template>

<script setup lang="ts">
import { MultiStringProperty } from '@/domain/PropertyDefinition.ts';
import { ref, watch } from 'vue';
import { TextProperty } from '@/domain/propertyTypes/Text.ts';
import { AttributesEditorEmits, AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts';
import { CdxToggleSwitch, CdxField } from '@wikimedia/codex';
import { CdxToggleSwitch, CdxField, CdxTextInput } from '@wikimedia/codex';

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

const minLengthInput = ref( props.property.minLength?.toString() ?? '' );
const maxLengthInput = ref( props.property.maxLength?.toString() ?? '' );
const lengthError = ref<string | null>( null );

watch( () => props.property.minLength, ( newVal ) => {
minLengthInput.value = newVal?.toString() ?? '';
} );

watch( () => props.property.maxLength, ( newVal ) => {
maxLengthInput.value = newVal?.toString() ?? '';
} );

const isPositiveInteger = ( value: string ): boolean => {
if ( value === '' ) {
return true;
}
const num = Number( value );
return Number.isInteger( num ) && num >= 1;
};

const validateLength = ( minValue: string, maxValue: string ): string | null => {
if ( minValue !== '' && !isPositiveInteger( minValue ) ) {
return mw.message( 'neowiki-property-editor-length-whole-number' ).text();
}

if ( maxValue !== '' && !isPositiveInteger( maxValue ) ) {
return mw.message( 'neowiki-property-editor-length-whole-number' ).text();
}

defineProps<AttributesEditorProps<MultiStringProperty>>();
const emit = defineEmits<AttributesEditorEmits<MultiStringProperty>>();
const min = minValue === '' ? undefined : Number( minValue );
const max = maxValue === '' ? undefined : Number( maxValue );
if ( min !== undefined && max !== undefined && min > max ) {
return mw.message( 'neowiki-property-editor-length-min-exceeds-max' ).text();
}

return null;
};

const updateMinLength = ( value: string ): void => {
minLengthInput.value = value;
const error = validateLength( value, maxLengthInput.value );

if ( error === null ) {
lengthError.value = null;
emit( 'update:property', { minLength: value === '' ? undefined : Number( value ) } );
return;
}

lengthError.value = error;
};

const updateMaxLength = ( value: string ): void => {
maxLengthInput.value = value;
const error = validateLength( minLengthInput.value, value );

if ( error === null ) {
lengthError.value = null;
emit( 'update:property', { maxLength: value === '' ? undefined : Number( value ) } );
return;
}

lengthError.value = error;
};

const updateMultiple = ( value: boolean ): void => {
emit( 'update:property', { multiple: value } );
Expand All @@ -44,3 +137,17 @@ const updateUniqueItems = ( value: boolean ): void => {
emit( 'update:property', { uniqueItems: value } );
};
</script>

<style lang="less">
.text-attributes__length-constraint {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}

.text-attributes__length-input.cdx-text-input {
min-width: unset;
width: 7em;
}
</style>
2 changes: 2 additions & 0 deletions resources/ext.neowiki/src/domain/propertyTypes/Text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export class TextType extends BasePropertyType<TextProperty, StringValue> {
...base,
multiple: json.multiple ?? false,
uniqueItems: json.uniqueItems ?? true,
minLength: json.minLength,
maxLength: json.maxLength,
} as TextProperty;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { VueWrapper } from '@vue/test-utils';
import { beforeEach, describe, expect, it } from 'vitest';
import { CdxField, CdxTextInput, ValidationMessages, ValidationStatusType } from '@wikimedia/codex';
import TextAttributesEditor from '@/components/SchemaEditor/Property/TextAttributesEditor.vue';
import { newTextProperty, TextProperty } from '@/domain/propertyTypes/Text';
import { AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts';
import { createTestWrapper, mockMwMessage } from '../../../VueTestHelpers.ts';

interface FieldProps {
status: ValidationStatusType;
messages: ValidationMessages;
}

describe( 'TextAttributesEditor', () => {
beforeEach( () => {
mockMwMessage( {
'neowiki-property-editor-length-whole-number': 'Must be a whole number of at least 1.',
'neowiki-property-editor-length-min-exceeds-max': 'Minimum cannot exceed maximum.',
} );
} );

function newWrapper( props: Partial<AttributesEditorProps<TextProperty>> = {} ): VueWrapper {
return createTestWrapper( TextAttributesEditor, {
property: newTextProperty( {} ),
...props,
} );
}

function getLengthFieldProps( wrapper: VueWrapper ): FieldProps {
const fields = wrapper.findAllComponents( CdxField );
// Length constraint is the last CdxField (after toggle switches)
return fields[ fields.length - 1 ].props() as FieldProps;
}

describe( 'displaying existing values', () => {
it( 'displays existing minLength and maxLength', () => {
const wrapper = newWrapper( {
property: newTextProperty( { minLength: 5, maxLength: 100 } ),
} );
const inputs = wrapper.findAllComponents( CdxTextInput );

expect( inputs[ 0 ].props( 'modelValue' ) ).toBe( '5' );
expect( inputs[ 1 ].props( 'modelValue' ) ).toBe( '100' );
} );

it( 'displays existing multiple and uniqueItems', () => {
const wrapper = newWrapper( {
property: newTextProperty( { multiple: true, uniqueItems: false } ),
} );
const toggles = wrapper.findAll( 'input[type="checkbox"]' );

expect( ( toggles[ 0 ].element as HTMLInputElement ).checked ).toBe( true );
expect( ( toggles[ 1 ].element as HTMLInputElement ).checked ).toBe( false );
} );
} );

describe( 'length constraint validation', () => {
it( 'shows no error when both fields are empty', () => {
const wrapper = newWrapper();

expect( getLengthFieldProps( wrapper ).status ).toBe( 'default' );
} );

it( 'shows no error for valid positive integers', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '5' );
expect( getLengthFieldProps( wrapper ).status ).toBe( 'default' );

await inputs[ 1 ].vm.$emit( 'update:model-value', '100' );
expect( getLengthFieldProps( wrapper ).status ).toBe( 'default' );
} );

it( 'shows error and does not emit for zero', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '0' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( wrapper ).messages ).toEqual( {
error: 'Must be a whole number of at least 1.',
} );
expect( wrapper.emitted( 'update:property' ) ).toBeFalsy();
} );

it( 'shows error and does not emit for negative numbers', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '-5' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( wrapper ).messages ).toEqual( {
error: 'Must be a whole number of at least 1.',
} );
expect( wrapper.emitted( 'update:property' ) ).toBeFalsy();
} );

it( 'shows error and does not emit for decimal numbers', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 1 ].vm.$emit( 'update:model-value', '5.5' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( wrapper ).messages ).toEqual( {
error: 'Must be a whole number of at least 1.',
} );
expect( wrapper.emitted( 'update:property' ) ).toBeFalsy();
} );

it( 'shows error and does not emit for non-numeric input', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', 'abc' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( wrapper ).messages ).toEqual( {
error: 'Must be a whole number of at least 1.',
} );
expect( wrapper.emitted( 'update:property' ) ).toBeFalsy();
} );

it( 'shows error when min exceeds max', async () => {
const wrapper = newWrapper( {
property: newTextProperty( { maxLength: 10 } ),
} );
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '20' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( wrapper ).messages ).toEqual( {
error: 'Minimum cannot exceed maximum.',
} );
expect( wrapper.emitted( 'update:property' ) ).toBeFalsy();
} );

it( 'shows error when max is less than min', async () => {
const wrapper = newWrapper( {
property: newTextProperty( { minLength: 20 } ),
} );
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 1 ].vm.$emit( 'update:model-value', '10' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );
expect( getLengthFieldProps( 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: newTextProperty( { maxLength: 10 } ),
} );
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '10' );

expect( getLengthFieldProps( wrapper ).status ).toBe( 'default' );
expect( wrapper.emitted( 'update:property' ) ).toBeTruthy();
} );

it( 'clears error when valid value is entered after invalid', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '-5' );
expect( getLengthFieldProps( wrapper ).status ).toBe( 'error' );

await inputs[ 0 ].vm.$emit( 'update:model-value', '5' );
expect( getLengthFieldProps( wrapper ).status ).toBe( 'default' );
} );
} );

describe( 'emitting updates', () => {
it( 'emits minLength when valid value is entered', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '10' );

expect( wrapper.emitted( 'update:property' ) ).toBeTruthy();
expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { minLength: 10 } ] );
} );

it( 'emits maxLength when valid value is entered', async () => {
const wrapper = newWrapper();
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 1 ].vm.$emit( 'update:model-value', '50' );

expect( wrapper.emitted( 'update:property' ) ).toBeTruthy();
expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { maxLength: 50 } ] );
} );

it( 'emits undefined when field is cleared', async () => {
const wrapper = newWrapper( {
property: newTextProperty( { minLength: 10 } ),
} );
const inputs = wrapper.findAllComponents( CdxTextInput );

await inputs[ 0 ].vm.$emit( 'update:model-value', '' );

expect( wrapper.emitted( 'update:property' ) ).toBeTruthy();
expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { minLength: undefined } ] );
} );

it( 'emits update when multiple is toggled', async () => {
const wrapper = newWrapper();

await wrapper.find( 'input[type="checkbox"]' ).setValue( true );

expect( wrapper.emitted( 'update:property' ) ).toBeTruthy();
expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { multiple: true } ] );
} );
} );
} );