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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 72 additions & 41 deletions resources/ext.neowiki/src/components/common/SchemaPicker.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
<template>
<div class="ext-neowiki-schema-picker">
<CdxCombobox
ref="comboboxRef"
<CdxLookup
v-if="schemasLoaded"
ref="lookupRef"
v-model:selected="selectedSchema"
v-model:input-value="inputText"
:menu-items="menuItems"
:placeholder="$i18n( 'neowiki-schema-picker-placeholder' ).text()"
@input="filterSchemas"
@input="recordTypedText"
@update:selected="onSelect"
@blur="reconcileOnBlur"
@blur="revertUncommittedTyping"
/>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { CdxCombobox } from '@wikimedia/codex';
import { computed, nextTick, ref, triggerRef, watch } from 'vue';
import { CdxLookup } from '@wikimedia/codex';
import type { MenuItemData } from '@wikimedia/codex';
import { normalizeSchemaName, useSchemaStore } from '@/stores/SchemaStore.ts';
import { useSchemaStore } from '@/stores/SchemaStore.ts';
import type { SchemaSummary } from '@/application/SchemaLookup.ts';

const props = defineProps<{
Expand All @@ -29,77 +31,106 @@ const emit = defineEmits<{
}>();

const schemaStore = useSchemaStore();
const selectedSchema = ref<string>( props.selected ?? '' );
const selectedSchema = ref<string | null>( props.selected ?? null );
const inputText = ref<string | number>( props.selected ?? '' );
const summaries = ref<SchemaSummary[]>( [] );
const query = ref<string>( '' );
const comboboxRef = ref<InstanceType<typeof CdxCombobox> | null>( null );
const schemasLoaded = ref( false );
// The field's text as last typed, or null while the field shows the committed schema
// rather than typing.
const typedText = ref<string | null>( null );
const lookupRef = ref<InstanceType<typeof CdxLookup> | null>( null );

const menuItems = computed<MenuItemData[]>( () => {
const matches = query.value === '' ?
const filter = ( typedText.value ?? '' ).trim().toLowerCase();
const matches = filter === '' ?
summaries.value :
summaries.value.filter( ( summary ) => summary.name.toLowerCase().includes( query.value ) );
summaries.value.filter( ( summary ) => summary.name.toLowerCase().includes( filter ) );
return matches.map( ( summary ) => ( {
label: summary.name,
value: summary.name,
description: summary.description || undefined
} ) );
} );

onMounted( async () => {
// CdxLookup decides whether focus opens the menu from the menu items it was created
// with, so the field is only rendered once the schemas have arrived. Loading them is
// also marked done when the request fails, so a failure leaves a usable field rather
// than none at all.
async function loadSchemas(): Promise<void> {
try {
summaries.value = await schemaStore.getAllSchemaSummaries();
} catch ( error ) {
console.error( 'Failed to load schemas for the picker:', error );
}
} );
schemasLoaded.value = true;
}

const schemasReady = loadSchemas();

// The filter is reset alongside the field: CdxLookup resolves a selection against the
// menu as it currently stands, and would empty the field for a schema the filter hides.
watch( () => props.selected, ( value ) => {
selectedSchema.value = value ?? '';
selectedSchema.value = selectableSchema( value ?? null );
inputText.value = value ?? '';
typedText.value = null;
} );

function findSchema( name: string ): SchemaSummary | undefined {
const normalized = normalizeSchemaName( name );
return summaries.value.find( ( summary ) => summary.name === normalized );
// CdxLookup takes the field's text for a selection from the matching menu entry and
// empties the field when there is none, so it is only handed a schema it lists. The
// field keeps showing the committed name either way.
function selectableSchema( schemaName: string | null ): string | null {
return summaries.value.some( ( summary ) => summary.name === schemaName ) ? schemaName : null;
}

function filterSchemas( event: Event ): void {
query.value = ( event.target as HTMLInputElement ).value.trim().toLowerCase();
// CdxLookup takes a change to its menu items as the answer to the user's keystroke, and
// marks the field as loading until one arrives. Every edit therefore has to produce a new
// list, including an edit that leaves the text exactly as it found it: pasting a name over
// its own selection, or a cancelled composition.
function recordTypedText( value: string ): void {
typedText.value = value;
triggerRef( typedText );
}

// CdxCombobox's `selected` tracks the typed text, not only menu picks, so only
// commit it when it resolves to an exact existing schema. The name is matched the
// way a save would normalise it (case-insensitive first letter, collapsed
// whitespace), and the schema's canonical name is emitted rather than the raw input.
// Resetting the filter on commit restores the full menu, so reopening the picker
// without leaving the field browses every schema again rather than the stale filter.
function onSelect( value: string ): void {
const schema = findSchema( value );
if ( schema && schema.name !== props.selected ) {
emit( 'select', schema.name );
query.value = '';
// CdxLookup reports a selection only for a menu entry the user picks, and null while
// they type, so a schema name typed out in full is not picked by itself.
//
// Setting the field text is load-bearing: left to do it itself, CdxLookup announces the
// name as typed input, which would re-apply the filter cleared on the next line.
// Restoring the full menu means reopening the picker without leaving the field browses
// every schema again rather than the last filter.
function onSelect( schemaName: string | null ): void {
if ( schemaName === null ) {
return;
}

inputText.value = schemaName;
typedText.value = null;

if ( schemaName !== props.selected ) {
emit( 'select', schemaName );
}
}

// On blur, snap `selected` back to the committed schema (empty for a target schema
// that has not been set yet) so unconfirmed typing reverts and the field only ever
// shows an existing schema. Clearing the query restores the full menu so the
// committed label renders.
function reconcileOnBlur(): void {
selectedSchema.value = props.selected ?? '';
query.value = '';
function revertUncommittedTyping(): void {
selectedSchema.value = selectableSchema( props.selected ?? null );
inputText.value = props.selected ?? '';
typedText.value = null;
emit( 'blur' );
}

function focus(): void {
const input = ( comboboxRef.value?.$el as HTMLElement )?.querySelector( 'input' );
// Waits for the schemas so that focus lands on a field whose menu can open right away.
async function focus(): Promise<void> {
await schemasReady;
await nextTick();
const input = ( lookupRef.value?.$el as HTMLElement )?.querySelector( 'input' );
input?.focus();
}

defineExpose( { focus } );
</script>

<style lang="less">
.ext-neowiki-schema-picker .cdx-combobox {
.ext-neowiki-schema-picker .cdx-lookup {
width: 100%;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ vi.mock( '@/composables/useSchemaPermissions.ts' );
const SchemaPickerStub = {
template: '<div class="schema-lookup-stub"></div>',
emits: [ 'select' ],
methods: {
focus: vi.fn(),
setup() {
return { focus: vi.fn() };
},
};

Expand Down Expand Up @@ -403,6 +403,30 @@ describe( 'SubjectCreatorDialog', () => {
);
} );

describe( 'Existing schema flow', () => {
it( 'focuses SchemaPicker when the dialog opens', async () => {
const wrapper = mountComponent();

subjectStore.subjectCreatorOpen = true;
await flushPromises();

const pickerVm = wrapper.findComponent( SchemaPicker ).vm as any;
expect( pickerVm.focus ).toHaveBeenCalled();
} );

it( 'focuses SchemaPicker when switching back to "Use existing"', async () => {
const wrapper = mountComponent();
await switchToNewSchema( wrapper );

wrapper.findComponent( { name: 'CdxToggleButtonGroup' } )
.vm.$emit( 'update:modelValue', 'existing' );
await flushPromises();

const pickerVm = wrapper.findComponent( SchemaPicker ).vm as any;
expect( pickerVm.focus ).toHaveBeenCalled();
} );
} );

describe( 'Create new schema flow', () => {
it( 'shows SchemaCreator when "Create new" is selected', async () => {
const wrapper = mountComponent();
Expand Down
Loading