From 02ed24a59ebd324cbcb4fb22ec75363e669f989b Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sat, 27 Jun 2026 20:26:02 +0200 Subject: [PATCH 1/8] Add an all-schemas summary data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/application/SchemaLookup.ts | 25 +++++++ .../components/SchemasPage/SchemasPage.vue | 7 +- .../src/persistence/RestSchemaRepository.ts | 13 ++++ .../ext.neowiki/src/stores/SchemaStore.ts | 25 +++++++ .../tests/stores/SchemaStore.spec.ts | 66 ++++++++++++++++++- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/resources/ext.neowiki/src/application/SchemaLookup.ts b/resources/ext.neowiki/src/application/SchemaLookup.ts index a98fb395e..670d9adda 100644 --- a/resources/ext.neowiki/src/application/SchemaLookup.ts +++ b/resources/ext.neowiki/src/application/SchemaLookup.ts @@ -1,9 +1,21 @@ import type { Schema, SchemaName } from '@/domain/Schema'; +export interface SchemaSummary { + name: string; + description: string; + propertyCount: number; +} + +export interface SchemaSummaryPage { + schemas: SchemaSummary[]; + totalRows: number; +} + export interface SchemaLookup { getSchema( schemaName: SchemaName ): Promise; getSchemaNames( search: string ): Promise; + getSchemaSummaries( offset: number, limit: number ): Promise; } @@ -31,6 +43,19 @@ export class InMemorySchemaLookup implements SchemaLookup { return [ ...this.schemaNames ]; } + public async getSchemaSummaries( offset: number, limit: number ): Promise { + const summaries = [ ...this.schemas.values() ].map( ( schema ) => ( { + name: schema.getName(), + description: schema.getDescription(), + propertyCount: [ ...schema.getPropertyDefinitions() ].length, + } ) ); + + return { + schemas: summaries.slice( offset, offset + limit ), + totalRows: summaries.length, + }; + } + public clearSchemas(): void { this.schemas.clear(); } diff --git a/resources/ext.neowiki/src/components/SchemasPage/SchemasPage.vue b/resources/ext.neowiki/src/components/SchemasPage/SchemasPage.vue index 3ac7ac339..96b766588 100644 --- a/resources/ext.neowiki/src/components/SchemasPage/SchemasPage.vue +++ b/resources/ext.neowiki/src/components/SchemasPage/SchemasPage.vue @@ -111,6 +111,7 @@ import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; import { useSchemaPermissions } from '@/composables/useSchemaPermissions.ts'; import { useSchemaStore } from '@/stores/SchemaStore.ts'; import { Schema } from '@/domain/Schema.ts'; +import type { SchemaSummary } from '@/application/SchemaLookup.ts'; import SchemaCreatorDialog from './SchemaCreatorDialog.vue'; import SchemaEditorDialog from '@/components/SchemaEditor/SchemaEditorDialog.vue'; import EditSummary from '@/components/common/EditSummary.vue'; @@ -167,12 +168,6 @@ function schemaUrl( name: string ): string { return mw.util.getUrl( `Schema:${ name }` ); } -interface SchemaSummary { - name: string; - description: string; - propertyCount: number; -} - async function fetchSchemas( offset: number, limit: number ): Promise { loading.value = true; pageSize.value = limit; diff --git a/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts b/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts index c135c3171..ef772ae8d 100644 --- a/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts +++ b/resources/ext.neowiki/src/persistence/RestSchemaRepository.ts @@ -1,6 +1,7 @@ import { Schema, type SchemaName } from '@/domain/Schema'; import type { HttpClient } from '@/infrastructure/HttpClient/HttpClient'; import type { SchemaRepository } from '@/application/SchemaRepository'; +import type { SchemaSummaryPage } from '@/application/SchemaLookup'; import { SchemaSerializer } from '@/persistence/SchemaSerializer.ts'; import { SchemaDeserializer } from '@/persistence/SchemaDeserializer.ts'; import { PageSaver } from '@/persistence/PageSaver.ts'; @@ -47,6 +48,18 @@ export class RestSchemaRepository implements SchemaRepository { return await response.json(); } + public async getSchemaSummaries( offset: number, limit: number ): Promise { + const response = await this.httpClient.get( + `${ this.mediaWikiRestApiUrl }/neowiki/v0/schemas?limit=${ limit }&offset=${ offset }`, + ); + + if ( !response.ok ) { + throw new Error( 'Error fetching schema summaries' ); + } + + return await response.json(); + } + public async saveSchema( schema: Schema, comment?: string ): Promise { const status = await this.pageSaver.savePage( `Schema:${ encodeURIComponent( schema.getName() ) }`, diff --git a/resources/ext.neowiki/src/stores/SchemaStore.ts b/resources/ext.neowiki/src/stores/SchemaStore.ts index cb321b245..6ed9b4eff 100644 --- a/resources/ext.neowiki/src/stores/SchemaStore.ts +++ b/resources/ext.neowiki/src/stores/SchemaStore.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia'; import { Schema } from '@/domain/Schema.ts'; import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import type { SchemaSummary } from '@/application/SchemaLookup.ts'; /** * Approximates MediaWiki title normalisation for a Schema name (schemas are @@ -16,6 +17,7 @@ export function normalizeSchemaName( name: string ): string { export const useSchemaStore = defineStore( 'schema', { state: () => ( { schemas: new Map(), + allSummaries: null as SchemaSummary[] | null, } ), getters: { getSchemas: ( state ) => state.schemas, @@ -47,6 +49,28 @@ export const useSchemaStore = defineStore( 'schema', { await Promise.all( schemaNames.map( ( name ) => this.getOrFetchSchema( name ) ) ); return schemaNames; }, + // Loads every Schema summary (name + description) once and caches it so the + // schema picker can show the full list and filter client-side. The cache is + // cleared on saveSchema. Pages through the summaries endpoint (capped at 50). + async getAllSchemaSummaries(): Promise { + if ( this.allSummaries !== null ) { + return this.allSummaries; + } + + const repository = NeoWikiExtension.getInstance().getSchemaRepository(); + const summaries: SchemaSummary[] = []; + + let page = await repository.getSchemaSummaries( 0, 50 ); + summaries.push( ...page.schemas ); + + while ( summaries.length < page.totalRows && page.schemas.length > 0 ) { + page = await repository.getSchemaSummaries( summaries.length, 50 ); + summaries.push( ...page.schemas ); + } + + this.allSummaries = summaries; + return summaries; + }, // Checks existence via the schema-names search (a 200 response) rather // than getOrFetchSchema, which 404s for a missing name — those 404s are // avoidable console/network noise when checking a not-yet-created name. @@ -60,6 +84,7 @@ export const useSchemaStore = defineStore( 'schema', { async saveSchema( schema: Schema, comment?: string ): Promise { await NeoWikiExtension.getInstance().getSchemaRepository().saveSchema( schema, comment ); this.setSchema( schema.getName(), schema ); + this.allSummaries = null; }, }, } ); diff --git a/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts b/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts index 76627b261..d258bab45 100644 --- a/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts +++ b/resources/ext.neowiki/tests/stores/SchemaStore.spec.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { normalizeSchemaName } from '@/stores/SchemaStore.ts'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { normalizeSchemaName, useSchemaStore } from '@/stores/SchemaStore.ts'; +import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { Schema } from '@/domain/Schema.ts'; +import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; describe( 'normalizeSchemaName', () => { it( 'upper-cases the first character', () => { @@ -23,3 +27,61 @@ describe( 'normalizeSchemaName', () => { expect( normalizeSchemaName( 'Validation Demo' ) ).toBe( 'Validation Demo' ); } ); } ); + +describe( 'SchemaStore getAllSchemaSummaries', () => { + + function summary( name: string ): { name: string; description: string; propertyCount: number } { + return { name, description: '', propertyCount: 0 }; + } + + function withRepository( repository: Record ): void { + vi.spyOn( NeoWikiExtension, 'getInstance' ).mockReturnValue( + { getSchemaRepository: () => repository } as unknown as NeoWikiExtension, + ); + } + + beforeEach( () => { + setActivePinia( createPinia() ); + } ); + + afterEach( () => { + vi.restoreAllMocks(); + } ); + + it( 'pages through every schema summary', async () => { + const getSchemaSummaries = vi.fn() + .mockResolvedValueOnce( { schemas: [ summary( 'A' ), summary( 'B' ) ], totalRows: 3 } ) + .mockResolvedValueOnce( { schemas: [ summary( 'C' ) ], totalRows: 3 } ); + withRepository( { getSchemaSummaries } ); + + const result = await useSchemaStore().getAllSchemaSummaries(); + + expect( result.map( ( item ) => item.name ) ).toEqual( [ 'A', 'B', 'C' ] ); + expect( getSchemaSummaries ).toHaveBeenNthCalledWith( 2, 2, 50 ); + } ); + + it( 'caches the summaries and does not refetch on the next call', async () => { + const getSchemaSummaries = vi.fn().mockResolvedValue( { schemas: [ summary( 'A' ) ], totalRows: 1 } ); + withRepository( { getSchemaSummaries } ); + const store = useSchemaStore(); + + await store.getAllSchemaSummaries(); + await store.getAllSchemaSummaries(); + + expect( getSchemaSummaries ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'refetches summaries after a schema is saved', async () => { + const getSchemaSummaries = vi.fn().mockResolvedValue( { schemas: [ summary( 'A' ) ], totalRows: 1 } ); + const saveSchema = vi.fn().mockResolvedValue( undefined ); + withRepository( { getSchemaSummaries, saveSchema } ); + const store = useSchemaStore(); + + await store.getAllSchemaSummaries(); + await store.saveSchema( new Schema( 'B', '', new PropertyDefinitionList( [] ) ) ); + await store.getAllSchemaSummaries(); + + expect( getSchemaSummaries ).toHaveBeenCalledTimes( 2 ); + } ); + +} ); From 578a5c5a5f4a8e3d56ffe0d47b00e9aac01c18ef Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sat, 27 Jun 2026 20:26:12 +0200 Subject: [PATCH 2/8] 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) --- extension.json | 3 +- i18n/en.json | 2 +- i18n/qqq.json | 1 + .../src/components/common/SchemaLookup.vue | 113 ++++++----- .../components/common/SchemaLookup.spec.ts | 187 ++++++++++-------- 5 files changed, 172 insertions(+), 134 deletions(-) diff --git a/extension.json b/extension.json index decb74b5f..c21d975c5 100644 --- a/extension.json +++ b/extension.json @@ -302,6 +302,7 @@ "CdxToggleSwitch", "CdxToggleButtonGroup", "CdxLookup", + "CdxCombobox", "CdxTable", "CdxInfoChip" ], @@ -412,7 +413,7 @@ "neowiki-subject-creator-schema-title", "neowiki-subject-creator-existing-schema", "neowiki-subject-creator-new-schema", - "neowiki-subject-creator-schema-search-placeholder", + "neowiki-schema-lookup-placeholder", "neowiki-schema-display-property-name", "neowiki-schema-display-property-type", "neowiki-schema-display-property-required", diff --git a/i18n/en.json b/i18n/en.json index aef3b2956..3eef8ab9d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -103,7 +103,7 @@ "neowiki-subject-creator-schema-title": "Have an existing schema?", "neowiki-subject-creator-existing-schema": "Use existing", "neowiki-subject-creator-new-schema": "Create new", - "neowiki-subject-creator-schema-search-placeholder": "Search for a schema", + "neowiki-schema-lookup-placeholder": "Select a schema", "neowiki-subject-creator-label-field": "Subject label", "neowiki-subject-creator-label-placeholder": "Enter a label for the subject", "neowiki-subject-creator-save": "Create subject", diff --git a/i18n/qqq.json b/i18n/qqq.json index fc292cf9f..d89dd9b5d 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -52,6 +52,7 @@ "neowiki-subject-editor-error": "Error notification title shown when updating a subject fails. $1 is the subject label.", "neowiki-subject-editor-validation-failed": "Toast title shown when the backend rejects a Subject save under enforcement. $1 is the Subject label.", "neowiki-subject-lookup-placeholder": "Placeholder text shown in the subject search input field.", + "neowiki-schema-lookup-placeholder": "Placeholder text shown in the schema picker input field.", "neowiki-subject-lookup-no-results": "Message shown in the subject lookup dropdown when no subjects match the search query.", "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.", diff --git a/resources/ext.neowiki/src/components/common/SchemaLookup.vue b/resources/ext.neowiki/src/components/common/SchemaLookup.vue index a0f7e8e98..63177249c 100644 --- a/resources/ext.neowiki/src/components/common/SchemaLookup.vue +++ b/resources/ext.neowiki/src/components/common/SchemaLookup.vue @@ -1,24 +1,23 @@ + + diff --git a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts b/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts index f8eed5d4a..666da3e99 100644 --- a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts +++ b/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts @@ -1,15 +1,25 @@ import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; import SchemaLookup from '@/components/common/SchemaLookup.vue'; import { createPinia, setActivePinia } from 'pinia'; import { useSchemaStore } from '@/stores/SchemaStore.ts'; -import { CdxLookup } from '@wikimedia/codex'; import { createI18nMock } from '../../VueTestHelpers.ts'; -import { Schema } from '@/domain/Schema.ts'; -import { PropertyDefinitionList } from '@/domain/PropertyDefinitionList.ts'; const $i18n = createI18nMock(); +const CdxComboboxStub = { + props: [ 'selected', 'menuItems' ], + emits: [ 'update:selected', 'input', 'blur' ], + template: '
', +}; + +const SUMMARIES = [ + { name: 'Product', description: 'A product', propertyCount: 2 }, + { name: 'Office', description: 'A physical location', propertyCount: 4 }, + { name: 'City', description: '', propertyCount: 3 }, +]; + describe( 'SchemaLookup', () => { let pinia: ReturnType; let schemaStore: any; @@ -23,123 +33,144 @@ describe( 'SchemaLookup', () => { }, plugins: [ pinia ], stubs: { - CdxLookup: true, + CdxCombobox: CdxComboboxStub, }, }, } ) ); + async function mountLoaded( props: Record = {} ): Promise { + const wrapper = mountComponent( props ); + await flushPromises(); + return wrapper; + } + + function typeText( combobox: VueWrapper, value: string ): void { + combobox.vm.$emit( 'input', { target: { value } } ); + } + beforeEach( () => { pinia = createPinia(); setActivePinia( pinia ); schemaStore = useSchemaStore(); - schemaStore.searchAndFetchMissingSchemas = vi.fn().mockResolvedValue( [] ); + schemaStore.getAllSchemaSummaries = vi.fn().mockResolvedValue( SUMMARIES ); } ); - it( 'searches for schemas when input changes', () => { - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); + describe( 'browsing and filtering', () => { + it( 'populates the menu with all schemas and their descriptions on mount', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + expect( combobox.props( 'menuItems' ) ).toEqual( [ + { label: 'Product', value: 'Product', description: 'A product' }, + { label: 'Office', value: 'Office', description: 'A physical location' }, + { label: 'City', value: 'City', description: undefined }, + ] ); + } ); - lookup.vm.$emit( 'input', 'test query' ); + it( 'filters the menu to schemas matching the typed text', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - expect( schemaStore.searchAndFetchMissingSchemas ).toHaveBeenCalledWith( 'test query' ); - } ); + typeText( combobox, 'off' ); + await nextTick(); - it( 'updates menu items with search results', async () => { - const mockResults = [ 'Schema1', 'Schema2' ]; - schemaStore.searchAndFetchMissingSchemas.mockResolvedValue( mockResults ); - schemaStore.schemas.set( 'Schema1', new Schema( 'Schema1', 'First description', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'Schema2', new Schema( 'Schema2', 'Second description', new PropertyDefinitionList( [] ) ) ); + expect( combobox.props( 'menuItems' ) ).toEqual( [ + { label: 'Office', value: 'Office', description: 'A physical location' }, + ] ); + } ); - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); + it( 'shows all schemas again when the input is cleared', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - lookup.vm.$emit( 'input', 'test' ); - await flushPromises(); + typeText( combobox, 'off' ); + typeText( combobox, '' ); + await nextTick(); - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'Schema1', value: 'Schema1', description: 'First description' }, - { label: 'Schema2', value: 'Schema2', description: 'Second description' }, - ] ); + expect( combobox.props( 'menuItems' ) ).toHaveLength( 3 ); + } ); } ); - it( 'omits description from menu items when schema has empty description', async () => { - schemaStore.searchAndFetchMissingSchemas.mockResolvedValue( [ 'WithDesc', 'NoDesc' ] ); - schemaStore.schemas.set( 'WithDesc', new Schema( 'WithDesc', 'Has a description', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'NoDesc', new Schema( 'NoDesc', '', new PropertyDefinitionList( [] ) ) ); + describe( 'committing a selection', () => { + it( 'emits the schema when an exact schema name is selected', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); + combobox.vm.$emit( 'update:selected', 'Office' ); - lookup.vm.$emit( 'input', 'test' ); - await flushPromises(); + expect( wrapper.emitted( 'select' )?.[ 0 ] ).toEqual( [ 'Office' ] ); + } ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'WithDesc', value: 'WithDesc', description: 'Has a description' }, - { label: 'NoDesc', value: 'NoDesc', description: undefined }, - ] ); - } ); + it( 'does not emit for a value that is not a schema name', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); + + combobox.vm.$emit( 'update:selected', 'Off' ); - it( 'discards stale search results when a newer request completes first', async () => { - let resolveFirst: ( value: string[] ) => void; - const firstCallPromise = new Promise( ( resolve ) => { - resolveFirst = resolve; + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); } ); + } ); - schemaStore.searchAndFetchMissingSchemas = vi.fn() - .mockReturnValueOnce( firstCallPromise ) - .mockResolvedValueOnce( [ 'SecondSchema' ] ); + describe( 'rejecting invalid input', () => { + it( 'reverts to the committed schema and restores the menu on blur', async () => { + const wrapper = await mountLoaded( { selected: 'Product' } ); + const combobox = wrapper.findComponent( CdxComboboxStub ); - schemaStore.schemas.set( 'FirstSchema', new Schema( 'FirstSchema', 'Stale', new PropertyDefinitionList( [] ) ) ); - schemaStore.schemas.set( 'SecondSchema', new Schema( 'SecondSchema', 'Fresh', new PropertyDefinitionList( [] ) ) ); + combobox.vm.$emit( 'update:selected', 'xyz' ); + combobox.vm.$emit( 'blur' ); + await nextTick(); - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); + expect( combobox.props( 'selected' ) ).toBe( 'Product' ); + expect( combobox.props( 'menuItems' ) ).toHaveLength( 3 ); + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); - lookup.vm.$emit( 'input', 'first' ); - lookup.vm.$emit( 'input', 'second' ); - await flushPromises(); + it( 'leaves a not-yet-set field empty on blur', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'SecondSchema', value: 'SecondSchema', description: 'Fresh' }, - ] ); + combobox.vm.$emit( 'update:selected', 'xyz' ); + combobox.vm.$emit( 'blur' ); + await nextTick(); - resolveFirst!( [ 'FirstSchema' ] ); - await flushPromises(); + expect( combobox.props( 'selected' ) ).toBe( '' ); + expect( wrapper.emitted( 'select' ) ).toBeFalsy(); + } ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ - { label: 'SecondSchema', value: 'SecondSchema', description: 'Fresh' }, - ] ); - } ); + it( 'emits blur so the consumer can mark the field touched', async () => { + const wrapper = await mountLoaded(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - it( 'reflects the selected prop on the lookup', () => { - const wrapper = mountComponent( { selected: 'Product' } ); - const lookup = wrapper.findComponent( CdxLookup ); + combobox.vm.$emit( 'blur' ); - expect( lookup.props( 'selected' ) ).toBe( 'Product' ); - expect( lookup.props( 'inputValue' ) ).toBe( 'Product' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ { label: 'Product', value: 'Product' } ] ); + expect( wrapper.emitted( 'blur' ) ).toBeTruthy(); + } ); } ); - it( 'updates the lookup when the selected prop changes after mount', async () => { - const wrapper = mountComponent(); - const lookup = wrapper.findComponent( CdxLookup ); + describe( 'reflecting the selected prop', () => { + it( 'shows the selected schema in the field', () => { + const wrapper = mountComponent( { selected: 'Product' } ); + const combobox = wrapper.findComponent( CdxComboboxStub ); - await wrapper.setProps( { selected: 'NewSchema' } ); + expect( combobox.props( 'selected' ) ).toBe( 'Product' ); + } ); - expect( lookup.props( 'selected' ) ).toBe( 'NewSchema' ); - expect( lookup.props( 'inputValue' ) ).toBe( 'NewSchema' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [ { label: 'NewSchema', value: 'NewSchema' } ] ); + it( 'updates the field when the selected prop changes', async () => { + const wrapper = mountComponent(); + const combobox = wrapper.findComponent( CdxComboboxStub ); - await wrapper.setProps( { selected: null } ); + await wrapper.setProps( { selected: 'NewSchema' } ); + expect( combobox.props( 'selected' ) ).toBe( 'NewSchema' ); - expect( lookup.props( 'inputValue' ) ).toBe( '' ); - expect( lookup.props( 'menuItems' ) ).toEqual( [] ); + await wrapper.setProps( { selected: null } ); + expect( combobox.props( 'selected' ) ).toBe( '' ); + } ); } ); it( 'exposes focus method', () => { - const CdxLookupStub = { + const CdxComboboxInputStub = { template: '
', }; @@ -150,7 +181,7 @@ describe( 'SchemaLookup', () => { }, plugins: [ pinia ], stubs: { - CdxLookup: CdxLookupStub, + CdxCombobox: CdxComboboxInputStub, }, }, } ); From 0977eedff27809f5ba15bc4792b70411d668dc93 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sat, 27 Jun 2026 20:26:23 +0200 Subject: [PATCH 3/8] 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) --- .../Property/RelationAttributesEditor.vue | 14 ++--- .../Property/RelationAttributesEditor.spec.ts | 54 +++++++++++++++++-- 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/resources/ext.neowiki/src/components/SchemaEditor/Property/RelationAttributesEditor.vue b/resources/ext.neowiki/src/components/SchemaEditor/Property/RelationAttributesEditor.vue index 537444075..cf1cc2b3f 100644 --- a/resources/ext.neowiki/src/components/SchemaEditor/Property/RelationAttributesEditor.vue +++ b/resources/ext.neowiki/src/components/SchemaEditor/Property/RelationAttributesEditor.vue @@ -25,8 +25,9 @@ {{ $i18n( 'neowiki-property-editor-target-schema' ).text() }} @@ -54,7 +55,7 @@ const emit = defineEmits>(); const relationInput = ref( props.property.relation || props.property.name.toString() ); watch( () => props.property.relation, ( newValue ) => { - relationInput.value = newValue || props.property.name.toString(); + relationInput.value = newValue; } ); onMounted( () => { @@ -69,18 +70,17 @@ const relationError = computed( () => null ); +const targetSchemaTouched = ref( false ); + const targetSchemaError = computed( () => - ( props.property.targetSchema ?? '' ).trim() === '' ? + targetSchemaTouched.value && ( props.property.targetSchema ?? '' ).trim() === '' ? mw.message( 'neowiki-property-editor-target-schema-required' ).text() : null ); const updateRelation = ( value: string ): void => { relationInput.value = value; - const trimmed = value.trim(); - if ( trimmed !== '' ) { - emit( 'update:property', { relation: trimmed } ); - } + emit( 'update:property', { relation: value.trim() } ); }; const updateTargetSchema = ( schemaName: string ): void => { diff --git a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts index 5da7be2e6..031e16d02 100644 --- a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts +++ b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts @@ -9,7 +9,7 @@ import { createI18nMock, FieldProps, setupMwMock } from '../../../VueTestHelpers const SchemaLookupStub = { props: [ 'selected' ], - emits: [ 'select' ], + emits: [ 'select', 'blur' ], template: '
', }; @@ -63,6 +63,14 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.findComponent( SchemaLookupStub ).props( 'selected' ) ).toBe( 'Office' ); } ); + it( 'passes null to SchemaLookup when no target schema is set', () => { + const wrapper = newWrapper( { + property: relationProperty( { targetSchema: '' } ), + } ); + + expect( wrapper.findComponent( SchemaLookupStub ).props( 'selected' ) ).toBe( null ); + } ); + it( 'displays the stored relation in the input', () => { const wrapper = newWrapper( { property: relationProperty( { relation: 'Has gadget' } ), @@ -78,6 +86,18 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( 'Main product' ); } ); + + it( 'clears the displayed relation when the stored relation is emptied', async () => { + const wrapper = newWrapper( { + property: relationProperty( { relation: 'Has product' } ), + } ); + + await wrapper.setProps( { + property: relationProperty( { relation: '' } ), + } ); + + expect( wrapper.findComponent( CdxTextInput ).props( 'modelValue' ) ).toBe( '' ); + } ); } ); describe( 'relation default', () => { @@ -115,6 +135,14 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { relation: 'Owns' } ] ); } ); + it( 'emits an empty relation when the field is cleared', async () => { + const wrapper = newWrapper(); + + await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', '' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { relation: '' } ] ); + } ); + it( 'emits targetSchema when the picker selects a schema', async () => { const wrapper = newWrapper(); @@ -123,6 +151,14 @@ describe( 'RelationAttributesEditor', () => { expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { targetSchema: 'Office' } ] ); } ); + it( 'emits an empty target schema when the picker is cleared', async () => { + const wrapper = newWrapper(); + + await wrapper.findComponent( SchemaLookupStub ).vm.$emit( 'select', '' ); + + expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { targetSchema: '' } ] ); + } ); + it( 'emits multiple when the checkbox is toggled', async () => { const wrapper = newWrapper(); @@ -148,10 +184,9 @@ describe( 'RelationAttributesEditor', () => { const props = fieldProps( wrapper, '.relation-attributes__relation' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Relation type is required.' } ); - expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); } ); - it( 'treats a whitespace-only relation as required and does not emit it', async () => { + it( 'treats a whitespace-only relation as required', async () => { const wrapper = newWrapper(); await wrapper.findComponent( CdxTextInput ).vm.$emit( 'update:modelValue', ' ' ); @@ -159,14 +194,23 @@ describe( 'RelationAttributesEditor', () => { const props = fieldProps( wrapper, '.relation-attributes__relation' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Relation type is required.' } ); - expect( wrapper.emitted( 'update:property' ) ).toBeFalsy(); } ); - it( 'shows a required error when the target schema is empty', () => { + it( 'does not show the target schema error before the field is touched', () => { const wrapper = newWrapper( { property: relationProperty( { targetSchema: '' } ), } ); + expect( fieldProps( wrapper, '.relation-attributes__target-schema' ).status ).toBe( 'default' ); + } ); + + it( 'shows a required error after the empty target schema field is blurred', async () => { + const wrapper = newWrapper( { + property: relationProperty( { targetSchema: '' } ), + } ); + + await wrapper.findComponent( SchemaLookupStub ).vm.$emit( 'blur' ); + const props = fieldProps( wrapper, '.relation-attributes__target-schema' ); expect( props.status ).toBe( 'error' ); expect( props.messages ).toEqual( { error: 'Target schema is required.' } ); From 610089c01582834f0436a086b40dc26ea2f95e24 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sat, 27 Jun 2026 21:42:33 +0200 Subject: [PATCH 4/8] 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) --- .../src/components/common/SchemaLookup.vue | 53 ++++++++++--------- .../ext.neowiki/src/stores/SchemaStore.ts | 18 +++---- .../Property/RelationAttributesEditor.spec.ts | 8 --- .../components/common/SchemaLookup.spec.ts | 30 +++++++++++ .../tests/stores/SchemaStore.spec.ts | 30 +++++++++-- 5 files changed, 91 insertions(+), 48 deletions(-) diff --git a/resources/ext.neowiki/src/components/common/SchemaLookup.vue b/resources/ext.neowiki/src/components/common/SchemaLookup.vue index 63177249c..ec0b561b7 100644 --- a/resources/ext.neowiki/src/components/common/SchemaLookup.vue +++ b/resources/ext.neowiki/src/components/common/SchemaLookup.vue @@ -13,7 +13,7 @@ diff --git a/resources/ext.neowiki/src/public-api.ts b/resources/ext.neowiki/src/public-api.ts index af2de850d..0124dc654 100644 --- a/resources/ext.neowiki/src/public-api.ts +++ b/resources/ext.neowiki/src/public-api.ts @@ -128,7 +128,7 @@ export { default as SchemaEditorDialog } from './components/SchemaEditor/SchemaE export { default as SchemaCreatorDialog } from './components/SchemasPage/SchemaCreatorDialog.vue'; export { default as SchemasPage } from './components/SchemasPage/SchemasPage.vue'; export { default as SchemaAbandonmentDialog } from './components/SubjectCreator/SchemaAbandonmentDialog.vue'; -export { default as SchemaLookup } from './components/common/SchemaLookup.vue'; +export { default as SchemaPicker } from './components/common/SchemaPicker.vue'; export { default as SubjectCreatorDialog } from './components/SubjectCreator/SubjectCreatorDialog.vue'; export { default as SubjectEditor } from './components/SubjectEditor/SubjectEditor.vue'; export { default as SubjectEditorDialog } from './components/SubjectEditor/SubjectEditorDialog.vue'; diff --git a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts index a84bbbb21..90517a3c1 100644 --- a/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts +++ b/resources/ext.neowiki/tests/components/SchemaEditor/Property/RelationAttributesEditor.spec.ts @@ -7,7 +7,7 @@ import { PropertyName } from '@/domain/PropertyDefinition.ts'; import { AttributesEditorProps } from '@/components/SchemaEditor/Property/AttributesEditorContract.ts'; import { createI18nMock, FieldProps, setupMwMock } from '../../../VueTestHelpers.ts'; -const SchemaLookupStub = { +const SchemaPickerStub = { props: [ 'selected' ], emits: [ 'select', 'blur' ], template: '
', @@ -37,7 +37,7 @@ describe( 'RelationAttributesEditor', () => { }, global: { mocks: { $i18n: createI18nMock() }, - stubs: { SchemaLookup: SchemaLookupStub }, + stubs: { SchemaPicker: SchemaPickerStub }, }, } ); } @@ -51,24 +51,24 @@ describe( 'RelationAttributesEditor', () => { const wrapper = newWrapper(); expect( wrapper.find( '.relation-attributes__relation' ).exists() ).toBe( true ); - expect( wrapper.findComponent( SchemaLookupStub ).exists() ).toBe( true ); + expect( wrapper.findComponent( SchemaPickerStub ).exists() ).toBe( true ); expect( wrapper.find( 'input[type="checkbox"]' ).exists() ).toBe( true ); } ); - it( 'passes the current target schema to SchemaLookup', () => { + it( 'passes the current target schema to SchemaPicker', () => { const wrapper = newWrapper( { property: relationProperty( { targetSchema: 'Office' } ), } ); - expect( wrapper.findComponent( SchemaLookupStub ).props( 'selected' ) ).toBe( 'Office' ); + expect( wrapper.findComponent( SchemaPickerStub ).props( 'selected' ) ).toBe( 'Office' ); } ); - it( 'passes null to SchemaLookup when no target schema is set', () => { + it( 'passes null to SchemaPicker when no target schema is set', () => { const wrapper = newWrapper( { property: relationProperty( { targetSchema: '' } ), } ); - expect( wrapper.findComponent( SchemaLookupStub ).props( 'selected' ) ).toBe( null ); + expect( wrapper.findComponent( SchemaPickerStub ).props( 'selected' ) ).toBe( null ); } ); it( 'displays the stored relation in the input', () => { @@ -146,7 +146,7 @@ describe( 'RelationAttributesEditor', () => { it( 'emits targetSchema when the picker selects a schema', async () => { const wrapper = newWrapper(); - await wrapper.findComponent( SchemaLookupStub ).vm.$emit( 'select', 'Office' ); + await wrapper.findComponent( SchemaPickerStub ).vm.$emit( 'select', 'Office' ); expect( wrapper.emitted( 'update:property' )?.[ 0 ] ).toEqual( [ { targetSchema: 'Office' } ] ); } ); @@ -201,7 +201,7 @@ describe( 'RelationAttributesEditor', () => { property: relationProperty( { targetSchema: '' } ), } ); - await wrapper.findComponent( SchemaLookupStub ).vm.$emit( 'blur' ); + await wrapper.findComponent( SchemaPickerStub ).vm.$emit( 'blur' ); const props = fieldProps( wrapper, '.relation-attributes__target-schema' ); expect( props.status ).toBe( 'error' ); diff --git a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts index 661091291..6f06d1d70 100644 --- a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts +++ b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts @@ -2,7 +2,7 @@ import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ref } from 'vue'; import SubjectCreatorDialog from '@/components/SubjectCreator/SubjectCreatorDialog.vue'; -import SchemaLookup from '@/components/common/SchemaLookup.vue'; +import SchemaPicker from '@/components/common/SchemaPicker.vue'; import SchemaCreator from '@/components/SchemaCreator/SchemaCreator.vue'; import EditSummary from '@/components/common/EditSummary.vue'; import { createPinia, setActivePinia } from 'pinia'; @@ -36,7 +36,7 @@ const NEW_SCHEMA_NAME = 'NewSchema'; vi.mock( '@/composables/useSchemaPermissions.ts' ); -const SchemaLookupStub = { +const SchemaPickerStub = { template: '
', emits: [ 'select' ], methods: { @@ -129,7 +129,7 @@ describe( 'SubjectCreatorDialog', () => { global: { plugins: [ pinia ], stubs: { - SchemaLookup: SchemaLookupStub, + SchemaPicker: SchemaPickerStub, SubjectEditor: SubjectEditorStub, SchemaCreator: SchemaCreatorStub, EditSummary: EditSummaryStub, @@ -246,7 +246,7 @@ describe( 'SubjectCreatorDialog', () => { expect( wrapper.find( '.schema-lookup-stub' ).exists() ).toBe( true ); expect( wrapper.find( '.cdx-toggle-button-group-stub' ).exists() ).toBe( true ); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.schema-lookup-stub' ).exists() ).toBe( false ); @@ -273,7 +273,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'shows label input and SubjectEditor after schema selection', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.cdx-text-input-stub' ).exists() ).toBe( true ); @@ -284,7 +284,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'defaults label to page title', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -294,7 +294,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'calls createMainSubject on save with correct arguments', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', 'test summary' ); @@ -312,7 +312,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'does not pass summary when it is empty', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -330,7 +330,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'calls createChildSubject when the page already has a main subject', async () => { const wrapper = mountComponent( {}, { pageHasMainSubject: true } ); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', 'test summary' ); @@ -351,7 +351,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -368,7 +368,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); @@ -386,7 +386,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'does not save when label is empty', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -413,7 +413,7 @@ describe( 'SubjectCreatorDialog', () => { expect( wrapper.find( '.ext-neowiki-subject-creator-continue' ).exists() ).toBe( true ); } ); - it( 'does not show SchemaLookup when "Create new" is selected', async () => { + it( 'does not show SchemaPicker when "Create new" is selected', async () => { const wrapper = mountComponent(); await switchToNewSchema( wrapper ); @@ -591,7 +591,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'shows back button after selecting a schema', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.ext-neowiki-subject-creator-back-button' ).exists() ).toBe( true ); @@ -600,7 +600,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'returns to schema selection when back button is clicked', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); expect( wrapper.find( '.subject-editor-stub' ).exists() ).toBe( true ); @@ -655,7 +655,7 @@ describe( 'SubjectCreatorDialog', () => { it( 'returns to schema selector when clicking back after selecting existing schema', async () => { const wrapper = mountComponent(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.find( '.ext-neowiki-subject-creator-back-button' ).trigger( 'click' ); @@ -672,7 +672,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -706,7 +706,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -729,7 +729,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -830,7 +830,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const labelInput = wrapper.find( '.cdx-text-input-stub' ); @@ -914,7 +914,7 @@ describe( 'SubjectCreatorDialog', () => { async function openSelectSchemaAndSave( wrapper: VueWrapper ): Promise { subjectStore.openSubjectCreator(); await flushPromises(); - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); await wrapper.findComponent( EditSummary ).vm.$emit( 'save', '' ); await flushPromises(); @@ -1033,7 +1033,7 @@ describe( 'SubjectCreatorDialog', () => { subjectStore.openSubjectCreator(); await flushPromises(); // Re-select schema so SubjectEditor renders again - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); const after = wrapper.findComponent( SubjectEditor ).props( 'serverViolations' ) as SubjectViolation[]; @@ -1050,7 +1050,7 @@ describe( 'SubjectCreatorDialog', () => { }; async function selectSchema( wrapper: VueWrapper ): Promise { - await wrapper.findComponent( SchemaLookup ).vm.$emit( 'select', SCHEMA_NAME ); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); await flushPromises(); } diff --git a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts b/resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts similarity index 97% rename from resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts rename to resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts index 3a0898689..ccbcfa564 100644 --- a/resources/ext.neowiki/tests/components/common/SchemaLookup.spec.ts +++ b/resources/ext.neowiki/tests/components/common/SchemaPicker.spec.ts @@ -1,7 +1,7 @@ import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { nextTick } from 'vue'; -import SchemaLookup from '@/components/common/SchemaLookup.vue'; +import SchemaPicker from '@/components/common/SchemaPicker.vue'; import { createPinia, setActivePinia } from 'pinia'; import { useSchemaStore } from '@/stores/SchemaStore.ts'; import { createI18nMock } from '../../VueTestHelpers.ts'; @@ -20,12 +20,12 @@ const SUMMARIES = [ { name: 'City', description: '', propertyCount: 3 }, ]; -describe( 'SchemaLookup', () => { +describe( 'SchemaPicker', () => { let pinia: ReturnType; let schemaStore: any; const mountComponent = ( props: Record = {} ): VueWrapper => ( - mount( SchemaLookup, { + mount( SchemaPicker, { props, global: { mocks: { @@ -204,7 +204,7 @@ describe( 'SchemaLookup', () => { template: '
', }; - const wrapper = mount( SchemaLookup, { + const wrapper = mount( SchemaPicker, { global: { mocks: { $i18n, From b770e500b0d6da49a28c59a1e05db9a5b1a8a29f Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Sat, 27 Jun 2026 23:24:08 +0200 Subject: [PATCH 8/8] Use the SchemaPicker name in the layout creator Follows the SchemaLookup-to-SchemaPicker component rename merged from the base branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue b/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue index 37e9c51d8..544f9ad86 100644 --- a/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue +++ b/resources/ext.neowiki/src/components/LayoutsPage/LayoutCreator.vue @@ -16,7 +16,7 @@ - @@ -66,7 +66,7 @@ import { NeoWikiServices } from '@/NeoWikiServices.ts'; import { Layout, type DisplayRule } from '@/domain/Layout.ts'; import type { PropertyDefinition } from '@/domain/PropertyDefinition.ts'; import DisplayRuleList from '@/components/LayoutEditor/DisplayRuleList.vue'; -import SchemaLookup from '@/components/common/SchemaLookup.vue'; +import SchemaPicker from '@/components/common/SchemaPicker.vue'; const emit = defineEmits<{ change: [];