Skip to content
6 changes: 4 additions & 2 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@
"CdxMessage",
"CdxToggleSwitch",
"CdxMenu",
"CdxToggleButtonGroup"
"CdxToggleButtonGroup",
"CdxLookup"
],
"packageFiles": [
"resources/ext.neowiki/dist/neowiki.js",
Expand Down Expand Up @@ -246,7 +247,8 @@
"neowiki-subject-creator-button-label",
"neowiki-subject-creator-schema-title",
"neowiki-subject-creator-existing-schema",
"neowiki-subject-creator-new-schema"
"neowiki-subject-creator-new-schema",
"neowiki-subject-creator-schema-search-placeholder"
],
"@group:": "TODO: Load code separately while in development. Remove later.",
"group": "ext.neowiki"
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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-cypher-raw-error-empty-query": "Empty Cypher query provided",
"neowiki-cypher-raw-error-write-query": "Write queries are not allowed",
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call this SchemaSelector or similar. It's different from the Lookup services we have, and the point of the component is to let the user choose a subject. Perhaps SubjectChooser?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume the last one was meant to be SchemaChooser.

Copy link
Member Author

@alistair3149 alistair3149 Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was picked to align with what Codex called the Lookup component. SchemaChooser sounds fine to me too. What do you think?

Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<div class="ext-neowiki-schema-lookup">
<CdxLookup
ref="lookupRef"
v-model:selected="selectedSchema"
:menu-items="menuItems"
:start-icon="cdxIconSearch"
:placeholder="$i18n( 'neowiki-subject-creator-schema-search-placeholder' ).text()"
@input="onLookupInput"
@update:selected="onSchemaSelected"
/>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CdxLookup } from '@wikimedia/codex';
import { cdxIconSearch } from '@wikimedia/codex-icons';
import type { MenuItemData } from '@wikimedia/codex';
import { useSchemaStore } from '@/stores/SchemaStore.ts';

const emit = defineEmits<{
'select': [ schemaName: string ];
}>();

const schemaStore = useSchemaStore();
const selectedSchema = ref<string | null>( null );
const menuItems = ref<MenuItemData[]>( [] );
const lookupRef = ref<InstanceType<typeof CdxLookup> | null>( null );

async function onLookupInput( value: string ): Promise<void> {
if ( !value ) {
menuItems.value = [];
return;
}

try {
const schemaNames = await schemaStore.searchAndFetchMissingSchemas( value );
menuItems.value = schemaNames.map( ( name ) => ( {
label: name,
value: name
} ) );
} catch ( error ) {
console.error( 'Error searching schemas:', error );
menuItems.value = [];
}
}

function onSchemaSelected( schemaName: string ): void {
if ( schemaName ) {
emit( 'select', schemaName );
}
}

function focus(): void {
// CdxLookup component does not expose a focus method,
// so we need to find the input element and focus it directly.
const input = ( lookupRef.value?.$el as HTMLElement )?.querySelector( 'input' );
input?.focus();
}

defineExpose( { focus } );
</script>
110 changes: 110 additions & 0 deletions resources/ext.neowiki/src/components/SubjectCreator/SubjectCreator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<template>
<div class="ext-neowiki-subject-creator">
<p>
{{ $i18n( 'neowiki-subject-creator-schema-title' ).text() }}
</p>

<CdxToggleButtonGroup
v-model="selectedValue"
class="ext-neowiki-subject-creator-schema-options"
:buttons="buttons"
/>

<div
v-if="selectedValue === 'existing'"
class="ext-neowiki-subject-creator-existing"
>
<SchemaLookup
ref="schemaLookupRef"
@select="onSchemaSelected"
/>
</div>

<div v-if="selectedValue === 'new'">
TODO: New schema UI
</div>
</div>
</template>

<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue';
import { CdxToggleButtonGroup } from '@wikimedia/codex';
import { cdxIconSearch, cdxIconAdd } from '@wikimedia/codex-icons';
import type { ButtonGroupItem } from '@wikimedia/codex';
import { Subject } from '@/domain/Subject.ts';
import SchemaLookup from '@/components/SubjectCreator/SchemaLookup.vue';

const emit = defineEmits<{
'draft': [ subject: Subject ];
}>();

const selectedValue = ref( 'existing' );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schemaLookupRef = ref<any | null>( null );

const buttons = [
{
value: 'existing',
label: mw.msg( 'neowiki-subject-creator-existing-schema' ),
icon: cdxIconSearch
},
{
value: 'new',
label: mw.msg( 'neowiki-subject-creator-new-schema' ),
icon: cdxIconAdd
}
] as ButtonGroupItem[];

onMounted( () => {
focusSchemaLookup( selectedValue.value );
} );

watch( selectedValue, focusSchemaLookup );

async function focusSchemaLookup( newValue: string ): Promise<void> {
await nextTick();
if ( newValue === 'existing' && schemaLookupRef.value ) {
schemaLookupRef.value.focus();
}
}

async function onSchemaSelected( schemaName: string ): Promise<void> {
if ( !schemaName ) {
return;
}

const subjectLabel = mw.config.get( 'wgTitle' );
if ( typeof subjectLabel !== 'string' ) {
mw.notify( 'Error preparing subject: No subject label found', { type: 'error' } );
return;
}

try {
const subject = Subject.createNew( subjectLabel, schemaName );
emit( 'draft', subject );
} catch ( error ) {
console.error( 'Error preparing subject:', error );
mw.notify( 'Error preparing subject: ' + String( error ), { type: 'error' } );
}
}
</script>

<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';

.ext-neowiki-subject-creator {
&-schema-options.cdx-toggle-button-group {
width: inherit;
display: flex;
flex-wrap: wrap;

.cdx-toggle-button {
flex-grow: 1;
}
}

&-existing {
margin-top: @spacing-100;
}
}
</style>
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<div class="ext-neowiki-subject-creator-container">
<CdxButton
class="ext-neowiki-subject-creator-trigger"
@click="open = true"
>
{{ $i18n( 'neowiki-subject-creator-button-label' ).text() }}
Expand All @@ -12,63 +13,55 @@
:use-close-button="true"
@default="open = false"
>
<p>
{{ $i18n( 'neowiki-subject-creator-schema-title' ).text() }}
</p>

<CdxToggleButtonGroup
v-model="selectedValue"
class="ext-neowiki-subject-creator-dialog-schema-options"
:buttons="buttons"
/>

<div v-if="selectedValue === 'existing'">
TODO:Existing schema UI
</div>

<div v-if="selectedValue === 'new'">
TODO: New schema UI
</div>
<SubjectCreator @draft="onSubjectCreated" />
</CdxDialog>

<SubjectEditorDialog
v-if="createdSubject"
v-model:open="isSubjectEditorOpen"
:subject="createdSubject"
:on-save="handleCreateSubject"
:on-save-schema="handleSaveSchema"
/>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CdxButton, CdxDialog, CdxToggleButtonGroup } from '@wikimedia/codex';
import { cdxIconSearch, cdxIconAdd } from '@wikimedia/codex-icons';
import type { ButtonGroupItem } from '@wikimedia/codex';
import { ref, shallowRef } from 'vue';
import { CdxButton, CdxDialog } from '@wikimedia/codex';
import { useSubjectStore } from '@/stores/SubjectStore.ts';
import { useSchemaStore } from '@/stores/SchemaStore.ts';
import { Subject } from '@/domain/Subject.ts';
import { Schema } from '@/domain/Schema.ts';
import SubjectCreator from '@/components/SubjectCreator/SubjectCreator.vue';
import SubjectEditorDialog from '@/components/SubjectEditor/SubjectEditorDialog.vue';

const open = ref( false );
const selectedValue = ref( 'existing' );

const buttons = [
{
value: 'existing',
label: mw.msg( 'neowiki-subject-creator-existing-schema' ),
icon: cdxIconSearch
},
{
value: 'new',
label: mw.msg( 'neowiki-subject-creator-new-schema' ),
icon: cdxIconAdd
}
] as ButtonGroupItem[];
const isSubjectEditorOpen = ref( false );
const createdSubject = shallowRef<Subject | null>( null );

</script>
const subjectStore = useSubjectStore();
const schemaStore = useSchemaStore();

<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
function onSubjectCreated( subject: Subject ): void {
createdSubject.value = subject;
isSubjectEditorOpen.value = true;
open.value = false;
}

.ext-neowiki-subject-creator-dialog {
&-schema-options.cdx-toggle-button-group {
width: inherit;
display: flex;
flex-wrap: wrap;
async function handleCreateSubject( subject: Subject, _summary: string ): Promise<void> {
await subjectStore.createMainSubject(
mw.config.get( 'wgArticleId' ),
subject.getLabel(),
subject.getSchemaName(),
subject.getStatements()
);
// Reload to show the new subject
window.location.reload();
}

.cdx-toggle-button {
flex-grow: 1;
}
}
async function handleSaveSchema( schema: Schema, comment?: string ): Promise<void> {
await schemaStore.saveSchema( schema, comment );
}
</style>

</script>
15 changes: 13 additions & 2 deletions resources/ext.neowiki/src/domain/Subject.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { SubjectId } from '@/domain/SubjectId';
import { StatementList } from '@/domain/StatementList';
import type { SubjectLookup } from '@/domain/SubjectLookup';
import type { SchemaName } from '@/domain/Schema';
import type { SubjectMap } from '@/domain/SubjectMap';
import type { SubjectId } from '@/domain/SubjectId';
import type { StatementList } from '@/domain/StatementList';
import type { PropertyName } from '@/domain/PropertyDefinition';
import type { Value } from '@/domain/Value';

Expand All @@ -16,6 +16,17 @@ export class Subject {
) {
}

public static createNew( label: string, schemaName: SchemaName ): Subject {
// TODO: The dummy ID is a temporary workaround.
// Should we make ID optional in Subject or create a separate NewSubject DTO?
return new Subject(
new SubjectId( 's11111111111111' ),
Comment on lines +20 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to think about this more. The returned object must be compatible with Subject, since that's being passed into SubjectEditorDialog. Although, it does not seem like the Id is actually being used there.

If NewSubject extends Subject, similar to SubjectWithContext, then we're still passing around a temporary Id, but it would just be hidden here.

Copy link
Member

@JeroenDeDauw JeroenDeDauw Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, more thinking is needed here.

Currently we generate IDs on the backend.

Some relevant TS:

export interface SubjectRepository extends SubjectLookup {

	createMainSubject(
		pageId: number,
		label: string,
		schemaName: SchemaName,
		statements: StatementList
	): Promise<SubjectId>;

SubjectEditor.vue:

const props = defineProps<{
	schemaStatements: StatementList;
	schemaProperties: PropertyDefinitionList;
}>();

TL;DR:

A Subject is not required in SubjectEditor or in the call to the API that creates a Subject.

label,
schemaName,
new StatementList( [] ),
);
}

public getId(): SubjectId {
return this.id;
}
Expand Down
19 changes: 17 additions & 2 deletions resources/ext.neowiki/tests/VueTestHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,25 @@ export function createTestWrapper<TComponent extends DefineComponent<any, any, a

export interface MwMockOptions {
messages?: Record<string, string | ( ( ...params: string[] ) => string )>;
functions?: ( 'message' | 'msg' | 'notify' )[];
config?: Record<string, any>;
functions?: (
'config' | 'message' | 'msg' | 'notify'
)[];
}

export function setupMwMock(
options: MwMockOptions = {},
): void {
const { messages: customMessages = {}, functions = [ 'message', 'msg', 'notify' ] } = options;
const {
messages: customMessages = {},
config: customConfig = {},
functions = [
'config',
'message',
'msg',
'notify',
],
} = options;

const mwMock: any = {};

Expand All @@ -47,6 +59,9 @@ export function setupMwMock(
};

const implementations: Record<string, any> = {
config: () => ( {
get: vi.fn( ( key: string ) => customConfig[ key ] ),
} ),
message: () => vi.fn( ( key: string, ...params: string[] ) => ( {
text: () => resolveMessage( key, params ),
parse: () => resolveMessage( key, params ),
Expand Down
Loading