Skip to content

Commit 900f6d6

Browse files
JeroenDeDauwclaudealistair3149
authored
Relation-property editing: clearable relation type and browsable target-schema picker (#953)
* Add an all-schemas summary data source Add a SchemaSummary type and getSchemaSummaries( offset, limit ) to the SchemaLookup port, with REST (GET /neowiki/v0/schemas) and in-memory implementations — reusing the endpoint the Schemas page already uses, no new backend. A cached, paginated getAllSchemaSummaries() store action loads every schema once (cleared on save) for the schema picker to filter client-side. SchemasPage reuses the shared SchemaSummary type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add a browsable, constrained target-schema picker Rebuild SchemaLookup on CdxCombobox so the field opens the full schema list on click (empty or filled) and filters as you type, with descriptions shown throughout. Only an exact existing schema is committed; unmatched text reverts to the committed value on blur, so the field never holds an invalid value. Reword the now-shared placeholder to "Select a schema" under a neutral neowiki-schema-lookup-placeholder key, stretch the field to full width, and register CdxCombobox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Reflect cleared attributes in the relation property editor Clearing the relation type now updates the model instead of silently keeping the previous value; the property name is used only as the on-create default. The target-schema required error shows only after the field has been touched (visited and left empty) rather than immediately on a newly added relation property. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden and simplify the schema picker from PR review Follow-ups from the #953 review: - Page getAllSchemaSummaries by request offset instead of loaded count. The summaries endpoint counts every Schema page in totalRows but omits ones it cannot load (restricted/malformed), so advancing the offset by the loaded count re-requested earlier names and duplicated entries in the cached picker list. - Handle a failed schema load in SchemaLookup's onMounted instead of leaving an unhandled rejection and a silently empty picker. - Commit the canonical schema name from the picker rather than the raw combobox text, so input with surrounding whitespace cannot store a target schema that does not resolve. - Simplify the picker's menu state to a computed over a query ref, removing the imperative showAllSchemas/toMenuItems bookkeeping, and drop the now-unused searchAndFetchMissingSchemas store action. - Drop the SchemaLookup clear test that drove an event the real component never emits; add coverage for the canonical-name commit, the no-op re-emit, the load-failure path, and offset pagination. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Reject empty relation type and target schema on schema save A Schema could be saved with a relation-type property whose relation (edge label) or targetSchema was empty or whitespace-only: the save-time JSON Schema used additionalProperties:true and never constrained those fields. An empty relation type then crashed Neo4j projection when a Subject populated the relation, via Cypher::escape('') throwing — for a user who need not be the one who saved the schema. An empty targetSchema was only caught at read time by SchemaName(''). Add an if/then to schemaContentSchema.json: when a property definition's type is "relation", relation and targetSchema are required and must contain a non-whitespace character. This rejects the invalid schema at save time through the existing SchemaContentValidator, the same path that already rejects an empty type, rather than corrupting on read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Rename the SchemaLookup component to SchemaPicker The Vue picker component shared its name with the unrelated SchemaLookup data-access interface (application/SchemaLookup.ts) — public-api.ts even re-exported both under the name SchemaLookup (one as a type, one as a value). Rename the component, its placeholder message, and its CSS class to SchemaPicker so the UI picker and the data-access lookup are clearly distinct. The data interface keeps the SchemaLookup name, paired with SchemaRepository. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Match the schema picker selection case-insensitively and restore its menu Follow-ups from the #953 review: - findSchema now normalises the typed text the way a save normalises a Schema name (case-insensitive first letter, collapsed whitespace) before matching, so typing an existing name in a different case (e.g. "office" for "Office") commits the canonical schema instead of reverting on blur. The menu filter was already case-insensitive, so the two now agree. - onSelect resets the filter on a committed selection, so reopening the picker without leaving the field browses every schema again rather than showing the stale filter from the previous query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Share one in-flight request when loading all schema summaries Follow-up from the #953 review: getAllSchemaSummaries cached only the resolved array, which is null for the whole paginated load, so several relation-property pickers mounting in the same render each ran a complete pagination. It now stores the in-flight request and hands it to concurrent callers, releasing it on completion and on failure (so a later call retries). saveSchema clears it alongside the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Reject relation type and target schema with surrounding whitespace on save Follow-up from the #953 review: The save-time JSON Schema pattern only rejected empty or whitespace-only values, so a padded relation type or target schema (e.g. "Likes ") passed validation and persisted. A padded target schema then matches no Neo4j label and a padded relation type projects to a distinct backtick-escaped relationship type. Anchor the pattern so leading and trailing whitespace is rejected at the same save-time boundary (SchemaContentValidator) that already rejects empties, rather than corrupting downstream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Assert whitespace-only relation input emits an empty relation Follow-up from the #953 review: updateRelation emits the trimmed value, so whitespace-only input emits an empty relation. That PR-introduced behaviour had lost its emission assertion when the whitespace test was reworded; add a focused test pinning it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: alistair3149 <alistair31494322@gmail.com>
1 parent 52c4198 commit 900f6d6

19 files changed

Lines changed: 779 additions & 320 deletions

File tree

extension.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@
302302
"CdxToggleSwitch",
303303
"CdxToggleButtonGroup",
304304
"CdxLookup",
305+
"CdxCombobox",
305306
"CdxTable",
306307
"CdxInfoChip"
307308
],
@@ -412,7 +413,7 @@
412413
"neowiki-subject-creator-schema-title",
413414
"neowiki-subject-creator-existing-schema",
414415
"neowiki-subject-creator-new-schema",
415-
"neowiki-subject-creator-schema-search-placeholder",
416+
"neowiki-schema-picker-placeholder",
416417
"neowiki-schema-display-property-name",
417418
"neowiki-schema-display-property-type",
418419
"neowiki-schema-display-property-required",

i18n/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"neowiki-subject-creator-schema-title": "Have an existing schema?",
104104
"neowiki-subject-creator-existing-schema": "Use existing",
105105
"neowiki-subject-creator-new-schema": "Create new",
106-
"neowiki-subject-creator-schema-search-placeholder": "Search for a schema",
106+
"neowiki-schema-picker-placeholder": "Select a schema",
107107
"neowiki-subject-creator-label-field": "Subject label",
108108
"neowiki-subject-creator-label-placeholder": "Enter a label for the subject",
109109
"neowiki-subject-creator-save": "Create subject",

i18n/qqq.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"neowiki-subject-editor-error": "Error notification title shown when updating a subject fails. $1 is the subject label.",
5353
"neowiki-subject-editor-validation-failed": "Toast title shown when the backend rejects a Subject save under enforcement. $1 is the Subject label.",
5454
"neowiki-subject-lookup-placeholder": "Placeholder text shown in the subject search input field.",
55+
"neowiki-schema-picker-placeholder": "Placeholder text shown in the schema picker input field.",
5556
"neowiki-subject-lookup-no-results": "Message shown in the subject lookup dropdown when no subjects match the search query.",
5657
"neowiki-subject-lookup-no-match": "Error message shown in the subject lookup when the user types text but does not select a subject from the dropdown results.",
5758

resources/ext.neowiki/src/application/SchemaLookup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import type { Schema, SchemaName } from '@/domain/Schema';
22

3+
export interface SchemaSummary {
4+
name: string;
5+
description: string;
6+
propertyCount: number;
7+
}
8+
9+
export interface SchemaSummaryPage {
10+
schemas: SchemaSummary[];
11+
totalRows: number;
12+
}
13+
314
export interface SchemaLookup {
415

516
getSchema( schemaName: SchemaName ): Promise<Schema>;
617
getSchemaNames( search: string ): Promise<string[]>;
18+
getSchemaSummaries( offset: number, limit: number ): Promise<SchemaSummaryPage>;
719

820
}
921

@@ -31,6 +43,19 @@ export class InMemorySchemaLookup implements SchemaLookup {
3143
return [ ...this.schemaNames ];
3244
}
3345

46+
public async getSchemaSummaries( offset: number, limit: number ): Promise<SchemaSummaryPage> {
47+
const summaries = [ ...this.schemas.values() ].map( ( schema ) => ( {
48+
name: schema.getName(),
49+
description: schema.getDescription(),
50+
propertyCount: [ ...schema.getPropertyDefinitions() ].length,
51+
} ) );
52+
53+
return {
54+
schemas: summaries.slice( offset, offset + limit ),
55+
totalRows: summaries.length,
56+
};
57+
}
58+
3459
public clearSchemas(): void {
3560
this.schemas.clear();
3661
}

resources/ext.neowiki/src/components/SchemaEditor/Property/RelationAttributesEditor.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
<template #label>
2525
{{ $i18n( 'neowiki-property-editor-target-schema' ).text() }}
2626
</template>
27-
<SchemaLookup
28-
:selected="property.targetSchema ?? null"
27+
<SchemaPicker
28+
:selected="property.targetSchema || null"
2929
@select="updateTargetSchema"
30+
@blur="targetSchemaTouched = true"
3031
/>
3132
</CdxField>
3233

@@ -46,15 +47,15 @@ import { computed, onMounted, ref, watch } from 'vue';
4647
import { CdxCheckbox, CdxField, CdxTextInput } from '@wikimedia/codex';
4748
import { RelationProperty } from '@/domain/propertyTypes/Relation.ts';
4849
import { AttributesEditorEmits, AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts';
49-
import SchemaLookup from '@/components/common/SchemaLookup.vue';
50+
import SchemaPicker from '@/components/common/SchemaPicker.vue';
5051
5152
const props = defineProps<AttributesEditorProps<RelationProperty>>();
5253
const emit = defineEmits<AttributesEditorEmits<RelationProperty>>();
5354
5455
const relationInput = ref( props.property.relation || props.property.name.toString() );
5556
5657
watch( () => props.property.relation, ( newValue ) => {
57-
relationInput.value = newValue || props.property.name.toString();
58+
relationInput.value = newValue;
5859
} );
5960
6061
onMounted( () => {
@@ -69,18 +70,17 @@ const relationError = computed<string | null>( () =>
6970
null
7071
);
7172
73+
const targetSchemaTouched = ref( false );
74+
7275
const targetSchemaError = computed<string | null>( () =>
73-
( props.property.targetSchema ?? '' ).trim() === '' ?
76+
targetSchemaTouched.value && ( props.property.targetSchema ?? '' ).trim() === '' ?
7477
mw.message( 'neowiki-property-editor-target-schema-required' ).text() :
7578
null
7679
);
7780
7881
const updateRelation = ( value: string ): void => {
7982
relationInput.value = value;
80-
const trimmed = value.trim();
81-
if ( trimmed !== '' ) {
82-
emit( 'update:property', { relation: trimmed } );
83-
}
83+
emit( 'update:property', { relation: value.trim() } );
8484
};
8585
8686
const updateTargetSchema = ( schemaName: string ): void => {

resources/ext.neowiki/src/components/SchemasPage/SchemasPage.vue

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import { NeoWikiExtension } from '@/NeoWikiExtension.ts';
111111
import { useSchemaPermissions } from '@/composables/useSchemaPermissions.ts';
112112
import { useSchemaStore } from '@/stores/SchemaStore.ts';
113113
import { Schema } from '@/domain/Schema.ts';
114+
import type { SchemaSummary } from '@/application/SchemaLookup.ts';
114115
import SchemaCreatorDialog from './SchemaCreatorDialog.vue';
115116
import SchemaEditorDialog from '@/components/SchemaEditor/SchemaEditorDialog.vue';
116117
import EditSummary from '@/components/common/EditSummary.vue';
@@ -167,12 +168,6 @@ function schemaUrl( name: string ): string {
167168
return mw.util.getUrl( `Schema:${ name }` );
168169
}
169170
170-
interface SchemaSummary {
171-
name: string;
172-
description: string;
173-
propertyCount: number;
174-
}
175-
176171
async function fetchSchemas( offset: number, limit: number ): Promise<void> {
177172
loading.value = true;
178173
pageSize.value = limit;

resources/ext.neowiki/src/components/SubjectCreator/SubjectCreatorDialog.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
v-if="selectedSchemaOption === 'existing'"
6161
class="ext-neowiki-subject-creator-existing"
6262
>
63-
<SchemaLookup
63+
<SchemaPicker
6464
ref="schemaLookupRef"
6565
@select="onSchemaSelected"
6666
/>
@@ -176,7 +176,7 @@ import SubjectEditor from '@/components/SubjectEditor/SubjectEditor.vue';
176176
import SchemaCreator from '@/components/SchemaCreator/SchemaCreator.vue';
177177
import type { SchemaCreatorExposes } from '@/components/SchemaCreator/SchemaCreator.vue';
178178
import EditSummary from '@/components/common/EditSummary.vue';
179-
import SchemaLookup from '@/components/common/SchemaLookup.vue';
179+
import SchemaPicker from '@/components/common/SchemaPicker.vue';
180180
import CloseConfirmationDialog from '@/components/common/CloseConfirmationDialog.vue';
181181
import SchemaAbandonmentDialog from '@/components/SubjectCreator/SchemaAbandonmentDialog.vue';
182182
import { useSchemaPermissions } from '@/composables/useSchemaPermissions.ts';

resources/ext.neowiki/src/components/common/SchemaLookup.vue

Lines changed: 0 additions & 94 deletions
This file was deleted.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<template>
2+
<div class="ext-neowiki-schema-picker">
3+
<CdxCombobox
4+
ref="comboboxRef"
5+
v-model:selected="selectedSchema"
6+
:menu-items="menuItems"
7+
:placeholder="$i18n( 'neowiki-schema-picker-placeholder' ).text()"
8+
@input="filterSchemas"
9+
@update:selected="onSelect"
10+
@blur="reconcileOnBlur"
11+
/>
12+
</div>
13+
</template>
14+
15+
<script setup lang="ts">
16+
import { computed, onMounted, ref, watch } from 'vue';
17+
import { CdxCombobox } from '@wikimedia/codex';
18+
import type { MenuItemData } from '@wikimedia/codex';
19+
import { normalizeSchemaName, useSchemaStore } from '@/stores/SchemaStore.ts';
20+
import type { SchemaSummary } from '@/application/SchemaLookup.ts';
21+
22+
const props = defineProps<{
23+
selected?: string | null;
24+
}>();
25+
26+
const emit = defineEmits<{
27+
'select': [ schemaName: string ];
28+
'blur': [];
29+
}>();
30+
31+
const schemaStore = useSchemaStore();
32+
const selectedSchema = ref<string>( props.selected ?? '' );
33+
const summaries = ref<SchemaSummary[]>( [] );
34+
const query = ref<string>( '' );
35+
const comboboxRef = ref<InstanceType<typeof CdxCombobox> | null>( null );
36+
37+
const menuItems = computed<MenuItemData[]>( () => {
38+
const matches = query.value === '' ?
39+
summaries.value :
40+
summaries.value.filter( ( summary ) => summary.name.toLowerCase().includes( query.value ) );
41+
return matches.map( ( summary ) => ( {
42+
label: summary.name,
43+
value: summary.name,
44+
description: summary.description || undefined
45+
} ) );
46+
} );
47+
48+
onMounted( async () => {
49+
try {
50+
summaries.value = await schemaStore.getAllSchemaSummaries();
51+
} catch ( error ) {
52+
console.error( 'Failed to load schemas for the picker:', error );
53+
}
54+
} );
55+
56+
watch( () => props.selected, ( value ) => {
57+
selectedSchema.value = value ?? '';
58+
} );
59+
60+
function findSchema( name: string ): SchemaSummary | undefined {
61+
const normalized = normalizeSchemaName( name );
62+
return summaries.value.find( ( summary ) => summary.name === normalized );
63+
}
64+
65+
function filterSchemas( event: Event ): void {
66+
query.value = ( event.target as HTMLInputElement ).value.trim().toLowerCase();
67+
}
68+
69+
// CdxCombobox's `selected` tracks the typed text, not only menu picks, so only
70+
// commit it when it resolves to an exact existing schema. The name is matched the
71+
// way a save would normalise it (case-insensitive first letter, collapsed
72+
// whitespace), and the schema's canonical name is emitted rather than the raw input.
73+
// Resetting the filter on commit restores the full menu, so reopening the picker
74+
// without leaving the field browses every schema again rather than the stale filter.
75+
function onSelect( value: string ): void {
76+
const schema = findSchema( value );
77+
if ( schema && schema.name !== props.selected ) {
78+
emit( 'select', schema.name );
79+
query.value = '';
80+
}
81+
}
82+
83+
// On blur, snap `selected` back to the committed schema (empty for a target schema
84+
// that has not been set yet) so unconfirmed typing reverts and the field only ever
85+
// shows an existing schema. Clearing the query restores the full menu so the
86+
// committed label renders.
87+
function reconcileOnBlur(): void {
88+
selectedSchema.value = props.selected ?? '';
89+
query.value = '';
90+
emit( 'blur' );
91+
}
92+
93+
function focus(): void {
94+
const input = ( comboboxRef.value?.$el as HTMLElement )?.querySelector( 'input' );
95+
input?.focus();
96+
}
97+
98+
defineExpose( { focus } );
99+
</script>
100+
101+
<style lang="less">
102+
.ext-neowiki-schema-picker .cdx-combobox {
103+
width: 100%;
104+
}
105+
</style>

resources/ext.neowiki/src/persistence/RestSchemaRepository.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Schema, type SchemaName } from '@/domain/Schema';
22
import type { HttpClient } from '@/infrastructure/HttpClient/HttpClient';
33
import type { SchemaRepository } from '@/application/SchemaRepository';
4+
import type { SchemaSummaryPage } from '@/application/SchemaLookup';
45
import { SchemaSerializer } from '@/persistence/SchemaSerializer.ts';
56
import { SchemaDeserializer } from '@/persistence/SchemaDeserializer.ts';
67
import { PageSaver } from '@/persistence/PageSaver.ts';
@@ -47,6 +48,18 @@ export class RestSchemaRepository implements SchemaRepository {
4748
return await response.json();
4849
}
4950

51+
public async getSchemaSummaries( offset: number, limit: number ): Promise<SchemaSummaryPage> {
52+
const response = await this.httpClient.get(
53+
`${ this.mediaWikiRestApiUrl }/neowiki/v0/schemas?limit=${ limit }&offset=${ offset }`,
54+
);
55+
56+
if ( !response.ok ) {
57+
throw new Error( 'Error fetching schema summaries' );
58+
}
59+
60+
return await response.json();
61+
}
62+
5063
public async saveSchema( schema: Schema, comment?: string ): Promise<void> {
5164
const status = await this.pageSaver.savePage(
5265
`Schema:${ encodeURIComponent( schema.getName() ) }`,

0 commit comments

Comments
 (0)