Skip to content

Commit b4b71b6

Browse files
alistair3149claude
authored andcommitted
Extract server-violation clearing into a shared composable
Replace the three ad-hoc per-input mechanisms for clearing stale server validation violations on edit with one useServerViolations composable, and fix the multi-value Text/Url field-level clear gap in the same stroke. The composable centralises the property-name filter (previously copied four times) and the neowiki-field message formatter (five times), and exposes emitClears( 'all' | number[] ): - 'all' clears every held violation by its own index. The aggregate inputs (Select, Relation) render one error for the whole property with no per-part slot, so on any edit every held violation is stale; a single null clear would never remove a part-indexed one. This is the part-indexed clearing the narrow fix delivered for Select/Relation, now shared. - a touched-index list clears the edited parts that carry a violation plus the shared field-level summary slot. Multi Text/Url passes its changed indices; single-value and field-level inputs pass []. The field-level clear in the number[] path fixes the bug where a required/uniqueItems violation shown in a multi Text/Url summary slot was never cleared on edit and lingered until the next dry-run validate. Display stays per-input: the aggregate, per-part-plus-summary, and field-level display models genuinely differ and cannot collapse. The wire contract and both dialogs' handleClearViolation are byte-identical, so the migration is safe — an unconverted input keeps working. useFieldServerViolation becomes a thin adapter over the composable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2bc11d8 commit b4b71b6

9 files changed

Lines changed: 595 additions & 143 deletions

File tree

resources/ext.neowiki/src/components/Value/RelationInput.vue

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
</template>
4848

4949
<script setup lang="ts">
50-
import { ref, watch, computed } from 'vue';
50+
import { ref, watch, computed, toRef } from 'vue';
5151
import { CdxField, CdxIcon, ValidationMessages } from '@wikimedia/codex';
5252
import { cdxIconInfo } from '@wikimedia/codex-icons';
5353
import NeoMultiLookupInput from '@/components/common/NeoMultiLookupInput.vue';
@@ -56,7 +56,7 @@ import { ValueInputEmits, ValueInputProps, ValueInputExposes } from '@/component
5656
import { RelationProperty, RelationType } from '@/domain/propertyTypes/Relation.ts';
5757
import { Value, ValueType, RelationValue, newRelation, relationValuesHaveSameTargets } from '@/domain/Value';
5858
import { NeoWikiServices } from '@/NeoWikiServices.ts';
59-
import { SubjectViolation } from '@/domain/SubjectViolation.ts';
59+
import { useServerViolations } from '@/composables/useServerViolations.ts';
6060
6161
const props = withDefaults(
6262
defineProps<ValueInputProps<RelationProperty>>(),
@@ -73,27 +73,18 @@ const emit = defineEmits<ValueInputEmits>();
7373
const internalValue = ref<RelationValue | undefined>( undefined );
7474
const singleHasUnmatchedText = ref( false );
7575
76-
function relevantServerViolations(): readonly SubjectViolation[] {
77-
const name = props.property.name.toString();
78-
return ( props.serverViolations ?? [] ).filter( ( v ) => v.propertyName === name );
79-
}
80-
81-
// Field-level server violation (NeoMultiLookupInput has no per-index slot).
82-
function serverFieldMessages(): ValidationMessages {
83-
const hit = relevantServerViolations()[ 0 ];
84-
if ( hit ) {
85-
return {
86-
error: mw.message( `neowiki-field-${ hit.code }`, ...( hit.args as string[] ) ).text()
87-
};
88-
}
89-
return {};
90-
}
76+
const { firstMessage, emitClears } = useServerViolations(
77+
toRef( props, 'property' ),
78+
toRef( props, 'serverViolations' ),
79+
emit
80+
);
9181
82+
// One aggregate error for the whole property (NeoMultiLookupInput has no per-index slot).
9283
const displayedFieldMessages = computed( (): ValidationMessages => {
93-
if ( singleHasUnmatchedText.value ) {
84+
if ( singleHasUnmatchedText.value || firstMessage.value === null ) {
9485
return {};
9586
}
96-
return serverFieldMessages();
87+
return { error: firstMessage.value };
9788
} );
9889
9990
const fieldStatus = computed( (): 'error' | 'default' => {
@@ -103,15 +94,6 @@ const fieldStatus = computed( (): 'error' | 'default' => {
10394
return displayedFieldMessages.value.error !== undefined ? 'error' : 'default';
10495
} );
10596
106-
function emitClearIfServerViolationPresent(): void {
107-
if ( relevantServerViolations().length > 0 ) {
108-
emit( 'clear-server-violation', {
109-
propertyName: props.property.name.toString(),
110-
valuePartIndex: null
111-
} );
112-
}
113-
}
114-
11597
function initializeInternalValue( value: Value | undefined ): void {
11698
if ( value && value.type === ValueType.Relation ) {
11799
const relValue = value as RelationValue;
@@ -156,7 +138,7 @@ function onSingleSelectionChanged( id: string | null ): void {
156138
}
157139
158140
emit( 'update:modelValue', newRelationValue );
159-
emitClearIfServerViolationPresent();
141+
emitClears( 'all' );
160142
}
161143
162144
function onSingleBlur( hasUnmatchedText: boolean ): void {
@@ -179,7 +161,7 @@ function onSelectionsChanged( ids: ( string | null )[] ): void {
179161
}
180162
181163
emit( 'update:modelValue', newRelationValue );
182-
emitClearIfServerViolationPresent();
164+
emitClears( 'all' );
183165
}
184166
185167
defineExpose<ValueInputExposes>( {

resources/ext.neowiki/src/components/Value/SelectInput.vue

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ import type { Value } from '@/domain/Value';
4242
</script>
4343

4444
<script setup lang="ts">
45-
import { ref, watch, computed, nextTick } from 'vue';
45+
import { ref, watch, computed, nextTick, toRef } from 'vue';
4646
import { CdxField, CdxIcon, CdxMultiselectLookup, CdxSelect } from '@wikimedia/codex';
4747
import type { ChipInputItem, MenuItemData } from '@wikimedia/codex';
4848
import { cdxIconInfo } from '@wikimedia/codex-icons';
4949
import { newStringValue, StringValue, ValueType } from '@/domain/Value';
5050
import { resolveSelectLabel, SelectProperty } from '@/domain/propertyTypes/Select.ts';
5151
import { ValueInputEmits, ValueInputExposes, ValueInputProps } from '@/components/Value/ValueInputContract.ts';
52-
import { SubjectViolation } from '@/domain/SubjectViolation.ts';
52+
import { useServerViolations } from '@/composables/useServerViolations.ts';
5353
5454
const props = withDefaults(
5555
defineProps<ValueInputProps<SelectProperty>>(),
@@ -61,19 +61,13 @@ const props = withDefaults(
6161
6262
const emit = defineEmits<ValueInputEmits>();
6363
64-
function relevantServerViolations(): readonly SubjectViolation[] {
65-
const name = props.property.name.toString();
66-
return ( props.serverViolations ?? [] ).filter( ( v ) => v.propertyName === name );
67-
}
68-
69-
const validationError = computed<string | null>( () => {
70-
// For Select (both single and multi), show the first relevant server violation.
71-
const hit = relevantServerViolations()[ 0 ];
72-
if ( hit ) {
73-
return mw.message( `neowiki-field-${ hit.code }`, ...( hit.args as string[] ) ).text();
74-
}
75-
return null;
76-
} );
64+
// For Select (single and multi) the field shows one aggregate error — the first
65+
// relevant server violation — so an edit clears every held violation ('all').
66+
const { firstMessage: validationError, emitClears } = useServerViolations(
67+
toRef( props, 'property' ),
68+
toRef( props, 'serverViolations' ),
69+
emit
70+
);
7771
7872
const selectPlaceholder = computed( () =>
7973
mw.message( 'neowiki-select-placeholder' ).text()
@@ -119,20 +113,11 @@ function getFilteredOptions(): MenuItemData[] {
119113
.map( ( option ) => ( { value: option.id, label: option.label } ) );
120114
}
121115
122-
function emitClearIfServerViolationPresent(): void {
123-
if ( relevantServerViolations().length > 0 ) {
124-
emit( 'clear-server-violation', {
125-
propertyName: props.property.name.toString(),
126-
valuePartIndex: null
127-
} );
128-
}
129-
}
130-
131116
function onSingleSelect( selected: string ): void {
132117
selection.value = selected ? [ selected ] : [];
133118
emit( 'update:modelValue', selection.value.length > 0 ?
134119
newStringValue( selection.value ) : undefined );
135-
emitClearIfServerViolationPresent();
120+
emitClears( 'all' );
136121
}
137122
138123
function onInput(): void {
@@ -159,7 +144,7 @@ watch( chips, () => {
159144
inputValue.value = '';
160145
menuItems.value = [];
161146
emit( 'update:modelValue', parts.length > 0 ? newStringValue( parts ) : undefined );
162-
emitClearIfServerViolationPresent();
147+
emitClears( 'all' );
163148
} );
164149
}, { deep: true } );
165150
Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
import { computed, ComputedRef, Ref } from 'vue';
1+
import { ComputedRef, Ref } from 'vue';
22
import { SubjectViolation } from '@/domain/SubjectViolation.ts';
33
import { PropertyDefinition } from '@/domain/PropertyDefinition.ts';
44
import { ValueInputEmitFunction } from '@/components/Value/ValueInputContract.ts';
5+
import { useServerViolations } from '@/composables/useServerViolations.ts';
56

67
interface FieldServerViolation {
78
validationError: ComputedRef<string | null>;
89
clearServerViolation: () => void;
910
}
1011

1112
/**
12-
* Field-level server-violation handling shared by the single-value inputs
13-
* (Boolean, Number, Date, DateTime). Merges a server-sourced violation for
14-
* this property as the displayed error and clears it when the user edits the
15-
* field.
16-
*
17-
* Only field-level violations (valuePartIndex null/undefined) are handled;
18-
* single-value inputs have no per-index slot. Text and Url (via
19-
* useStringValueInput) merge per-index violations into their per-input slots;
20-
* Select and Relation do their own inline lookup, surfacing the first
21-
* violation for the property at field level.
13+
* Field-level server-violation handling for the single-value inputs (Boolean,
14+
* Number, Date, DateTime). A thin adapter over useServerViolations: these
15+
* inputs have no per-part slot, so only the field-level violation (valuePartIndex
16+
* null/undefined) is displayed, and clearing passes an empty touched-index set
17+
* so exactly that violation is dropped — a per-index violation is neither shown
18+
* nor cleared here.
2219
*
2320
* @param property The field's Property Definition; violations are matched on its name.
2421
* @param serverViolations The violations passed to this input.
@@ -31,31 +28,10 @@ export function useFieldServerViolation<P extends PropertyDefinition>(
3128
emit: ValueInputEmitFunction,
3229
formatArg: ( arg: string ) => string = ( arg ) => arg,
3330
): FieldServerViolation {
34-
const fieldLevelHit = (): SubjectViolation | undefined =>
35-
( serverViolations.value ?? [] ).find(
36-
( v ) => v.propertyName === property.value.name.toString() &&
37-
( v.valuePartIndex === null || v.valuePartIndex === undefined ),
38-
);
39-
40-
const validationError = computed<string | null>( () => {
41-
const hit = fieldLevelHit();
42-
if ( hit ) {
43-
return mw.message(
44-
`neowiki-field-${ hit.code }`,
45-
...( hit.args as string[] ).map( formatArg ),
46-
).text();
47-
}
48-
return null;
49-
} );
50-
51-
function clearServerViolation(): void {
52-
if ( fieldLevelHit() ) {
53-
emit( 'clear-server-violation', {
54-
propertyName: property.value.name.toString(),
55-
valuePartIndex: null,
56-
} );
57-
}
58-
}
31+
const { fieldLevelMessage, emitClears } = useServerViolations( property, serverViolations, emit, formatArg );
5932

60-
return { validationError, clearServerViolation };
33+
return {
34+
validationError: fieldLevelMessage,
35+
clearServerViolation: () => emitClears( [] ),
36+
};
6137
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { computed, ComputedRef, Ref } from 'vue';
2+
import { SubjectViolation } from '@/domain/SubjectViolation.ts';
3+
import { PropertyDefinition } from '@/domain/PropertyDefinition.ts';
4+
import { ValueInputEmitFunction } from '@/components/Value/ValueInputContract.ts';
5+
6+
/**
7+
* Which on-screen surfaces an edit could have resolved, so the matching stale
8+
* server violations can be optimistically cleared:
9+
*
10+
* - `'all'` — the input renders one aggregate error for the whole property with
11+
* no per-part slot (Select, Relation), so every held violation is stale on any
12+
* edit. Each is cleared by its own index (numeric or null).
13+
* - a list of value-part indices — the input renders an independent slot per
14+
* part (multi Text/Url), so only the edited slots are cleared, and the shared
15+
* field-level summary slot is cleared alongside them. A single-value or
16+
* field-level input passes `[]` to clear only that summary slot.
17+
*/
18+
export type ClearScope = 'all' | readonly number[];
19+
20+
export interface UseServerViolationsReturn {
21+
relevant: () => readonly SubjectViolation[];
22+
format: ( violation: SubjectViolation ) => string;
23+
firstMessage: ComputedRef<string | null>;
24+
fieldLevelMessage: ComputedRef<string | null>;
25+
emitClears: ( touched: ClearScope ) => void;
26+
}
27+
28+
function isFieldLevel( violation: SubjectViolation ): boolean {
29+
return violation.valuePartIndex === null || violation.valuePartIndex === undefined;
30+
}
31+
32+
/**
33+
* Shared server-violation handling for the value inputs. Centralises the
34+
* property-name filter and the `neowiki-field-` message formatter (each was
35+
* copied across four/five inputs) and, crucially, the optimistic
36+
* clear-on-edit logic — which differs only by how each input DISPLAYS its
37+
* violations. Display stays in the individual inputs, because the aggregate,
38+
* per-part-plus-summary, and field-level models genuinely differ.
39+
*
40+
* The parent dialog drops violations by exact (propertyName, valuePartIndex)
41+
* match, so every clear must carry the held violation's own index; a single
42+
* null clear never removes a part-indexed one.
43+
*
44+
* @param property The field's Property Definition; violations match on its name.
45+
* @param serverViolations The violations passed to this input (may be undefined).
46+
* @param emit The component's emit function; used for clear-server-violation.
47+
* @param formatArg Per-arg formatter; Date/DateTime format their bounds for display.
48+
*/
49+
export function useServerViolations<P extends PropertyDefinition>(
50+
property: Ref<P>,
51+
serverViolations: Ref<readonly SubjectViolation[] | undefined>,
52+
emit: ValueInputEmitFunction,
53+
formatArg: ( arg: string ) => string = ( arg ) => arg,
54+
): UseServerViolationsReturn {
55+
function propertyName(): string {
56+
return property.value.name.toString();
57+
}
58+
59+
function relevant(): readonly SubjectViolation[] {
60+
const name = propertyName();
61+
return ( serverViolations.value ?? [] ).filter( ( v ) => v.propertyName === name );
62+
}
63+
64+
function format( violation: SubjectViolation ): string {
65+
return mw.message(
66+
`neowiki-field-${ violation.code }`,
67+
...( violation.args as string[] ).map( formatArg ),
68+
).text();
69+
}
70+
71+
const firstMessage = computed<string | null>( () => {
72+
const hit = relevant()[ 0 ];
73+
return hit ? format( hit ) : null;
74+
} );
75+
76+
const fieldLevelMessage = computed<string | null>( () => {
77+
const hit = relevant().find( isFieldLevel );
78+
return hit ? format( hit ) : null;
79+
} );
80+
81+
function emitClears( touched: ClearScope ): void {
82+
const held = relevant();
83+
const name = propertyName();
84+
85+
if ( touched === 'all' ) {
86+
for ( const violation of held ) {
87+
emit( 'clear-server-violation', { propertyName: name, valuePartIndex: violation.valuePartIndex } );
88+
}
89+
return;
90+
}
91+
92+
for ( const index of touched ) {
93+
if ( held.some( ( v ) => v.valuePartIndex === index ) ) {
94+
emit( 'clear-server-violation', { propertyName: name, valuePartIndex: index } );
95+
}
96+
}
97+
if ( held.some( isFieldLevel ) ) {
98+
emit( 'clear-server-violation', { propertyName: name, valuePartIndex: null } );
99+
}
100+
}
101+
102+
return { relevant, format, firstMessage, fieldLevelMessage, emitClears };
103+
}

0 commit comments

Comments
 (0)