diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index eb07049f5..92d572588 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -17,9 +17,11 @@ number, date, relation, and so on. ## 2. Create a Subject -A Subject is one thing described with a Schema. Open any wiki page and pick **Create subject** from the page tools, -fill in the values, and save. The page's Main Subject renders automatically as an infobox, and the **Data** tab -lets you view and edit all its Subjects. +A Subject is one thing described with a Schema. Pick **Create subject** in the sidebar, or the **Create** button on +the Schema's own page (it names the Schema, so **Create Person** on `Schema:Person`), name the page the Subject will +live on, fill in the values, and save. The new page's Main Subject renders automatically as an infobox, and the +**Data** tab lets you view and edit all its Subjects. To attach a Subject to a page that already exists, open that +page and pick **Create subject here** from the page tools. ## 3. Render a View in wikitext diff --git a/extension.json b/extension.json index 555a8d523..745e65556 100644 --- a/extension.json +++ b/extension.json @@ -180,7 +180,8 @@ "Schemas": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialSchemas", "Layouts": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialLayouts", "Mappings": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialMappings", - "GraphStores": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialGraphStores" + "GraphStores": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialGraphStores", + "CreateSubject": "ProfessionalWiki\\NeoWiki\\EntryPoints\\SpecialPages\\SpecialCreateSubject" }, "Actions": { @@ -597,7 +598,15 @@ "neowiki-subject-creator-schema-created", "neowiki-subject-creator-creating-schema", "neowiki-subject-creator-back", + "neowiki-subject-creator-page-field", + "neowiki-subject-creator-page-has-main-subject", + "neowiki-subject-creator-create-page-summary", + "neowiki-subject-creator-create-page-error", + "neowiki-subject-creator-page-taken", + "neowiki-subject-creator-page-read-error", + "neowiki-createsubject-button", "neowiki-edit-schema", + "neowiki-schema-create-subject", "neowiki-schema-delete", "neowiki-schema-editor-summary-default", "neowiki-schema-editor-success", diff --git a/i18n/_Aliases.php b/i18n/_Aliases.php index dd736d6a7..8c1c24f67 100644 --- a/i18n/_Aliases.php +++ b/i18n/_Aliases.php @@ -8,4 +8,5 @@ 'Layouts' => [ 'Layouts' ], 'Mappings' => [ 'Mappings' ], 'GraphStores' => [ 'GraphStores' ], + 'CreateSubject' => [ 'CreateSubject' ], ]; diff --git a/i18n/en.json b/i18n/en.json index aa8a5d638..8ede9858d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -19,6 +19,7 @@ "specialpages-group-neowiki": "NeoWiki", "neowiki-special-schemas": "Schemas", + "neowiki-special-createsubject": "Create subject", "neowiki-property-type-text": "Text", "neowiki-property-type-url": "URL", @@ -39,7 +40,7 @@ "neowiki-neojson-description": "Editing neo slot of \"$1\"", "neowiki-page-tools-label": "NeoWiki", - "neowiki-page-tools-create-subject": "Create subject", + "neowiki-page-tools-create-subject": "Create subject here", "neowiki-page-tools-manage-subjects": "Manage subjects", "neowiki-page-tools-view-subjects": "View subjects", "neowiki-page-tools-edit-json": "View or edit JSON", @@ -154,6 +155,13 @@ "neowiki-subject-creator-continue": "Continue", "neowiki-subject-creator-schema-created": "Schema created successfully", "neowiki-subject-creator-back": "Back", + "neowiki-subject-creator-page-field": "Page", + "neowiki-subject-creator-page-has-main-subject": "This page already has a Main Subject, $1. The new Subject will be added alongside it.", + "neowiki-subject-creator-create-page-summary": "Page created for a new Subject", + "neowiki-subject-creator-create-page-error": "The page \"$1\" could not be created.", + "neowiki-subject-creator-page-taken": "The page \"$1\" was created in the meantime. Pick it to add the Subject to it.", + "neowiki-subject-creator-page-read-error": "The page \"$1\" could not be read. Pick it again to retry.", + "neowiki-createsubject-button": "Create subject", "neowiki-schema-display-property-name": "Property", "neowiki-schema-display-property-type": "Type", @@ -230,6 +238,8 @@ "neowiki-schemas-column-properties": "Properties", "neowiki-schemas-empty": "No schemas have been created yet.", "neowiki-schema-sidebar-all-schemas": "All schemas", + "neowiki-sidebar-create-subject": "Create subject", + "neowiki-schema-create-subject": "Create $1", "neowiki-schema-delete": "Delete schema", @@ -339,7 +349,7 @@ "neowiki-managesubjects-tab": "Data", "neowiki-managesubjects-title": "Subjects on $1", "neowiki-managesubjects-not-applicable": "Subject management is not available on this page.", - "neowiki-managesubjects-add-button": "Add subject", + "neowiki-managesubjects-add-button": "Create subject", "neowiki-managesubjects-empty-title": "No subjects yet", "neowiki-managesubjects-empty-description": "A subject describes one thing with structured data.", "neowiki-managesubjects-no-main-title": "No main subject set", diff --git a/i18n/qqq.json b/i18n/qqq.json index 194c3bf80..c1338a74b 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -17,6 +17,7 @@ "action-neowiki-admin": "{{doc-action|neowiki-admin}}", "specialpages-group-neowiki": "Group heading for NeoWiki special pages on [[Special:SpecialPages]].", "neowiki-special-schemas": "Title and description of the [[Special:Schemas]] page.", + "neowiki-special-createsubject": "Title and description of the [[Special:CreateSubject]] page, which creates a page together with its Main Subject. A subpage names the Schema to use, e.g. Special:CreateSubject/Person.", "neowiki-schema-label": "Names a subject's schema. $1 is the schema name or a link to it. Also used as the accessible name of the schema badge, whose visible text is the bare name.", "neowiki-cypher-raw-error-json-encode": "Error message shown when JSON encoding of query results fails.", @@ -40,7 +41,7 @@ "neowiki-view-error-unknown-arg": "Error shown when {{#view}} receives an unrecognized named argument. $1 is the offending argument name.", "neowiki-page-tools-label": "Heading for the NeoWiki section in the MediaWiki sidebar (shown as a group label in legacy skins and inside the page tools menu in modern Vector).", - "neowiki-page-tools-create-subject": "Label for the sidebar link that opens the 'create subject' dialog. The dialog creates a main subject if the page has none, or a child subject otherwise.", + "neowiki-page-tools-create-subject": "Label for the page tool that creates a Subject on the page being viewed, as opposed to [[Special:CreateSubject]], which creates the page too. The dialog it opens creates the page's Main Subject if it has none, or another Subject otherwise.", "neowiki-page-tools-manage-subjects": "Label for the sidebar link to the Subject management page, shown to users who may edit. See neowiki-page-tools-view-subjects for the read-only variant.", "neowiki-page-tools-view-subjects": "Read-only counterpart of neowiki-page-tools-manage-subjects: label for the sidebar link to the Subject management page, shown to users who lack edit permission.", "neowiki-page-tools-edit-json": "Label for the sidebar link that navigates to Special:NeoJson for the current page. Only visible when development UIs are enabled.", @@ -52,6 +53,13 @@ "neowiki-subject-creator-continue": "Label for the button that continues from schema creation to subject editing in the subject creator dialog.", "neowiki-subject-creator-schema-created": "Success notification shown after creating a schema.", "neowiki-subject-creator-back": "Aria label for the back button in the subject creator dialog, used to return to schema selection.", + "neowiki-subject-creator-page-field": "Label of the field in the 'create subject' dialog, when opened from [[Special:CreateSubject]], that picks the page the Subject will be stored on: an existing page or a title to create.", + "neowiki-subject-creator-page-has-main-subject": "Note in the 'create subject' dialog when the chosen page already has a Main Subject. Parameters:\n* $1 - the display name of that Main Subject", + "neowiki-subject-creator-create-page-summary": "Edit summary of the empty page created to hold a Subject, used when the user entered none.", + "neowiki-subject-creator-create-page-error": "Error shown at the page field of the 'create subject' dialog when the page could not be created. Parameters:\n* $1 - the page title", + "neowiki-subject-creator-page-taken": "Error shown at the page field of the 'create subject' dialog when the title to create was taken between choosing it and saving. Parameters:\n* $1 - the page title", + "neowiki-subject-creator-page-read-error": "Error shown at the page field of the 'create subject' dialog when the chosen page's Subjects could not be read, so the dialog cannot tell whether it has a Main Subject. Parameters:\n* $1 - the page title", + "neowiki-createsubject-button": "Label of the button on [[Special:CreateSubject]] that opens the 'create subject' dialog again after it was closed.", "neowiki-schema-editor-summary-default": "Default edit summary used when updating a schema via the UI.", "neowiki-schema-editor-success": "Success notification shown after updating a schema. $1 is the schema name.", @@ -113,6 +121,8 @@ "neowiki-schemas-column-properties": "Column header for the number of property definitions in the schemas list table on [[Special:Schemas]].", "neowiki-schemas-empty": "Message shown when no schemas exist yet on [[Special:Schemas]].", "neowiki-schema-sidebar-all-schemas": "Label for the link in the NeoWiki section of the sidebar on Schema pages that navigates to [[Special:Schemas]].", + "neowiki-sidebar-create-subject": "Label for the link in the NeoWiki section of the sidebar that navigates to [[Special:CreateSubject]], where a Subject is created together with the page it lives on.", + "neowiki-schema-create-subject": "Label of the button on a Schema page that opens [[Special:CreateSubject]] with that Schema chosen. Parameters:\n* $1 - the Schema name", "neowiki-schema-delete": "Aria label for the delete button on a Schema row in the schemas list.", diff --git a/resources/ext.neowiki/src/application/SubjectPermissionHints.ts b/resources/ext.neowiki/src/application/SubjectPermissionHints.ts index 510d3dd76..1848786b8 100644 --- a/resources/ext.neowiki/src/application/SubjectPermissionHints.ts +++ b/resources/ext.neowiki/src/application/SubjectPermissionHints.ts @@ -14,4 +14,6 @@ export interface SubjectPermissionHints { canDeleteSubject( subjectId: SubjectId ): Promise; canCreateMainSubject(): Promise; + + canCreateSubjectPage(): Promise; } diff --git a/resources/ext.neowiki/src/components/CreateSubjectPage/CreateSubjectPage.vue b/resources/ext.neowiki/src/components/CreateSubjectPage/CreateSubjectPage.vue new file mode 100644 index 000000000..fd7f92438 --- /dev/null +++ b/resources/ext.neowiki/src/components/CreateSubjectPage/CreateSubjectPage.vue @@ -0,0 +1,36 @@ + + + diff --git a/resources/ext.neowiki/src/components/SchemaDisplay/SchemaDisplay.vue b/resources/ext.neowiki/src/components/SchemaDisplay/SchemaDisplay.vue index 5ff116c39..1f26d553e 100644 --- a/resources/ext.neowiki/src/components/SchemaDisplay/SchemaDisplay.vue +++ b/resources/ext.neowiki/src/components/SchemaDisplay/SchemaDisplay.vue @@ -11,6 +11,7 @@ @@ -72,7 +73,7 @@ @@ -612,6 +781,15 @@ defineExpose( { hasChanged } ); } } + &-page-field { + margin-top: @spacing-100; + } + + &-page-note { + margin: @spacing-50 0 0; + color: @color-subtle; + } + &-label-field { margin-top: @spacing-100; } diff --git a/resources/ext.neowiki/src/components/SubjectsManager/MoveSubjectDialog.vue b/resources/ext.neowiki/src/components/SubjectsManager/MoveSubjectDialog.vue index 5b715677c..ec3e9f063 100644 --- a/resources/ext.neowiki/src/components/SubjectsManager/MoveSubjectDialog.vue +++ b/resources/ext.neowiki/src/components/SubjectsManager/MoveSubjectDialog.vue @@ -81,6 +81,7 @@ import type { PageChoice } from '@/components/common/PageChoice.ts'; import { SubjectId } from '@/domain/SubjectId.ts'; import { useSubjectStore } from '@/stores/SubjectStore.ts'; import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; +import { createEmptyPage, PageCreationError } from '@/persistence/createEmptyPage.ts'; const props = defineProps<{ open: boolean; @@ -174,7 +175,10 @@ async function onMove( summary: string ): Promise { let targetPageId = chosen.pageId; if ( targetPageId === null ) { - targetPageId = await createTargetPage( chosen.title, summary ); + targetPageId = await createEmptyPage( + chosen.title, + summary || mw.msg( 'neowiki-managesubjects-move-create-page-summary-default' ) + ); // Recorded on the choice as well: if the move then fails, a retry has to move onto the // page just created rather than try to create it a second time. target.value = { pageId: targetPageId, title: chosen.title }; @@ -196,45 +200,11 @@ async function onMove( summary: string ): Promise { } } -/** - * The page is created through MediaWiki's own API rather than by the move, which has no page - * creation of its own and would bypass the createpage right if it did. It is created only once the - * user confirms, so an abandoned dialog leaves no empty page behind. - */ -async function createTargetPage( title: string, summary: string ): Promise { - let response; - - try { - response = await new mw.Api().create( - title, - { summary: summary || mw.msg( 'neowiki-managesubjects-move-create-page-summary-default' ) }, - '' - ); - } catch ( error ) { - // Everything that goes wrong here is about the page - an invalid title, a namespace the user - // may not create in, a filter - so it is reported as such rather than as a failed move. The - // title already being taken has its own message. - if ( codeOf( error ) === 'articleexists' ) { - throw error; - } - - throw new Error( mw.msg( 'neowiki-managesubjects-move-create-page-error', title ) ); - } - - if ( response.result !== 'Success' ) { - throw new Error( mw.msg( 'neowiki-managesubjects-move-create-page-error', title ) ); - } - - return response.pageid; -} - -function codeOf( error: unknown ): string | undefined { - return typeof error === 'string' ? error : ( error as { code?: string } )?.code; -} - function messageFor( error: unknown ): string { - if ( codeOf( error ) === 'articleexists' ) { - return mw.msg( 'neowiki-managesubjects-move-page-taken' ); + if ( error instanceof PageCreationError ) { + return error.titleTaken() ? + mw.msg( 'neowiki-managesubjects-move-page-taken' ) : + mw.msg( 'neowiki-managesubjects-move-create-page-error', error.title ); } if ( error instanceof Error ) { diff --git a/resources/ext.neowiki/src/composables/useSubjectPermissions.ts b/resources/ext.neowiki/src/composables/useSubjectPermissions.ts index 5100f49a1..fb441b805 100644 --- a/resources/ext.neowiki/src/composables/useSubjectPermissions.ts +++ b/resources/ext.neowiki/src/composables/useSubjectPermissions.ts @@ -7,7 +7,9 @@ export interface SubjectPermissions { canCreateChildSubject: Ref; canEditSubject: Ref; canDeleteSubject: Ref; + canCreateSubjectPage: Ref; checkPermissions: ( pageId: number ) => Promise; + checkCreateSubjectPagePermission: () => Promise; } export function useSubjectPermissions(): SubjectPermissions { @@ -15,6 +17,7 @@ export function useSubjectPermissions(): SubjectPermissions { const canCreateChildSubject = ref( false ); const canEditSubject = ref( false ); const canDeleteSubject = ref( false ); + const canCreateSubjectPage = ref( false ); const hints: SubjectPermissionHints = NeoWikiServices.getSubjectPermissionHints(); async function checkPermissions( pageId: number ): Promise { @@ -38,11 +41,22 @@ export function useSubjectPermissions(): SubjectPermissions { } } + async function checkCreateSubjectPagePermission(): Promise { + try { + canCreateSubjectPage.value = await hints.canCreateSubjectPage(); + } catch ( error ) { + console.error( 'Failed to check subject page creation permission:', error ); + canCreateSubjectPage.value = false; + } + } + return { canCreateMainSubject, canCreateChildSubject, canEditSubject, canDeleteSubject, + canCreateSubjectPage, checkPermissions, + checkCreateSubjectPagePermission, }; } diff --git a/resources/ext.neowiki/src/neowiki.ts b/resources/ext.neowiki/src/neowiki.ts index 28f3962fb..956577d0b 100644 --- a/resources/ext.neowiki/src/neowiki.ts +++ b/resources/ext.neowiki/src/neowiki.ts @@ -12,6 +12,7 @@ import SchemasPage from '@/components/SchemasPage/SchemasPage.vue'; import LayoutsPage from '@/components/LayoutsPage/LayoutsPage.vue'; import MappingsPage from '@/components/MappingsPage/MappingsPage.vue'; import SubjectsManagerPage from '@/components/SubjectsManager/SubjectsManagerPage.vue'; +import CreateSubjectPage from '@/components/CreateSubjectPage/CreateSubjectPage.vue'; import { NeoWikiExtension } from '@/NeoWikiExtension.ts'; import { SchemaName } from '@/domain/Schema.ts'; import type { LayoutName } from '@/domain/Layout.ts'; @@ -211,6 +212,22 @@ function initializeSubjectsManagerPage(): void { } ); } +function initializeCreateSubjectPage(): void { + queueMicrotask( () => { + const createSubjectPage = document.getElementById( 'ext-neowiki-create-subject' ); + + if ( createSubjectPage !== null ) { + const ext = NeoWikiExtension.getInstance(); + const schemaName = createSubjectPage.dataset.mwNeowikiSchema; + + const app = createMwApp( CreateSubjectPage, { schemaName } ).directive( 'tooltip', CdxTooltip ); + app.use( ext.getPinia() ); + NeoWikiServices.registerServices( app ); + mountNeoWikiApp( app, createSubjectPage ); + } + } ); +} + const isTestEnvironment = typeof window !== 'undefined' && ( window as unknown as { neoWikiTestMode?: boolean } ).neoWikiTestMode === true; @@ -223,4 +240,5 @@ if ( !isTestEnvironment ) { initializeLayoutsPage(); initializeMappingsPage(); initializeSubjectsManagerPage(); + initializeCreateSubjectPage(); } diff --git a/resources/ext.neowiki/src/persistence/RightsBasedSubjectPermissionHints.ts b/resources/ext.neowiki/src/persistence/RightsBasedSubjectPermissionHints.ts index 24ec18fd6..e50dd0539 100644 --- a/resources/ext.neowiki/src/persistence/RightsBasedSubjectPermissionHints.ts +++ b/resources/ext.neowiki/src/persistence/RightsBasedSubjectPermissionHints.ts @@ -31,6 +31,11 @@ export class RightsBasedSubjectPermissionHints implements SubjectPermissionHints return this.canEditPage(); } + public async canCreateSubjectPage(): Promise { + const rights = await this.rightsFetcher.getRights(); + return rights.includes( 'createpage' ) && rights.includes( 'edit' ); + } + private async canEditPage(): Promise { const rights = await this.rightsFetcher.getRights(); return rights.includes( 'edit' ); diff --git a/resources/ext.neowiki/src/persistence/createEmptyPage.ts b/resources/ext.neowiki/src/persistence/createEmptyPage.ts new file mode 100644 index 000000000..ff0e9ee34 --- /dev/null +++ b/resources/ext.neowiki/src/persistence/createEmptyPage.ts @@ -0,0 +1,37 @@ +export class PageCreationError extends Error { + public constructor( + public readonly title: string, + public readonly code: string | undefined, + ) { + super( `Could not create the page "${ title }"` ); + this.name = 'PageCreationError'; + } + + public titleTaken(): boolean { + return this.code === 'articleexists'; + } +} + +/** + * Creates an empty page through MediaWiki's own API rather than through a NeoWiki endpoint, so the + * createpage right is enforced by core. Resolves to the new page's id. + */ +export async function createEmptyPage( title: string, summary: string ): Promise { + let response; + + try { + response = await new mw.Api().create( title, { summary }, '' ); + } catch ( error ) { + throw new PageCreationError( title, apiErrorCode( error ) ); + } + + if ( response.result !== 'Success' ) { + throw new PageCreationError( title, undefined ); + } + + return response.pageid; +} + +function apiErrorCode( error: unknown ): string | undefined { + return typeof error === 'string' ? error : ( error as { code?: string } )?.code; +} diff --git a/resources/ext.neowiki/tests/components/CreateSubjectPage/CreateSubjectPage.spec.ts b/resources/ext.neowiki/tests/components/CreateSubjectPage/CreateSubjectPage.spec.ts new file mode 100644 index 000000000..528997a80 --- /dev/null +++ b/resources/ext.neowiki/tests/components/CreateSubjectPage/CreateSubjectPage.spec.ts @@ -0,0 +1,57 @@ +import { mount, VueWrapper } from '@vue/test-utils'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import CreateSubjectPage from '@/components/CreateSubjectPage/CreateSubjectPage.vue'; +import SubjectCreatorDialog from '@/components/SubjectCreator/SubjectCreatorDialog.vue'; +import { useSubjectStore } from '@/stores/SubjectStore.ts'; +import { createI18nMock, setupMwMock } from '../../VueTestHelpers.ts'; + +const SubjectCreatorDialogStub = { + template: '
', + props: [ 'pageHasMainSubject', 'choosePage', 'initialSchemaName' ], +}; + +describe( 'CreateSubjectPage', () => { + let pinia: ReturnType; + + function mountPage( props: Record = {} ): VueWrapper { + return mount( CreateSubjectPage, { + props, + global: { + plugins: [ pinia ], + mocks: { $i18n: createI18nMock() }, + stubs: { SubjectCreatorDialog: SubjectCreatorDialogStub, CdxIcon: true }, + }, + } ); + } + + beforeEach( () => { + setupMwMock( { functions: [ 'msg' ] } ); + pinia = createPinia(); + setActivePinia( pinia ); + } ); + + it( 'opens the creator as soon as the page loads', () => { + mountPage(); + + expect( useSubjectStore().subjectCreatorOpen ).toBe( true ); + } ); + + it( 'hands the creator the page choice and the pinned schema', () => { + const wrapper = mountPage( { schemaName: 'Person' } ); + const dialog = wrapper.findComponent( SubjectCreatorDialog ); + + expect( dialog.props( 'choosePage' ) ).toBe( true ); + expect( dialog.props( 'initialSchemaName' ) ).toBe( 'Person' ); + } ); + + it( 'reopens the creator from its button after it was closed', async () => { + const wrapper = mountPage(); + const store = useSubjectStore(); + store.closeSubjectCreator(); + + await wrapper.find( 'button' ).trigger( 'click' ); + + expect( store.subjectCreatorOpen ).toBe( true ); + } ); +} ); diff --git a/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplay.spec.ts b/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplay.spec.ts index 35f3cb159..b3ac79e5b 100644 --- a/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplay.spec.ts +++ b/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplay.spec.ts @@ -25,6 +25,19 @@ vi.mock( '@/composables/useSchemaPermissions.ts', () => ( { } ), } ) ); +let grantedRight = false; +const canCreateSubjectPageRef = ref( false ); +const checkCreateSubjectPagePermissionMock = vi.fn( async (): Promise => { + canCreateSubjectPageRef.value = grantedRight; +} ); + +vi.mock( '@/composables/useSubjectPermissions.ts', () => ( { + useSubjectPermissions: () => ( { + canCreateSubjectPage: canCreateSubjectPageRef, + checkCreateSubjectPagePermission: checkCreateSubjectPagePermissionMock, + } ), +} ) ); + const getSchemaMock = vi.fn(); function mountComponent( schema: Schema ): VueWrapper { @@ -54,6 +67,9 @@ describe( 'SchemaDisplay', () => { setActivePinia( createPinia() ); canEditSchemaRef.value = false; checkEditPermissionMock.mockClear(); + grantedRight = false; + canCreateSubjectPageRef.value = false; + checkCreateSubjectPagePermissionMock.mockClear(); getSchemaMock.mockReset(); } ); @@ -67,6 +83,15 @@ describe( 'SchemaDisplay', () => { expect( header.props( 'canEditSchema' ) ).toBe( false ); } ); + it( 'tells the header the user may create a subject page', async () => { + grantedRight = true; + + const wrapper = mountComponent( newSchema() ); + await flushPromises(); + + expect( wrapper.findComponent( SchemaDisplayHeader ).props( 'canCreateSubject' ) ).toBe( true ); + } ); + it( 'renders property names, types, and required status', () => { const schema = newSchema( { properties: new PropertyDefinitionList( [ diff --git a/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplayHeader.spec.ts b/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplayHeader.spec.ts index a8d7788ab..fca914025 100644 --- a/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplayHeader.spec.ts +++ b/resources/ext.neowiki/tests/components/SchemaDisplay/SchemaDisplayHeader.spec.ts @@ -5,11 +5,11 @@ import { Schema } from '@/domain/Schema.ts'; import { setupMwMock, createI18nMock } from '../../VueTestHelpers.ts'; import { newSchema } from '@/TestHelpers.ts'; -function mountComponent( schema: Schema, canEditSchema: boolean = false ): VueWrapper { - setupMwMock( { functions: [ 'msg' ] } ); +function mountComponent( schema: Schema, canEditSchema: boolean = false, canCreateSubject: boolean = false ): VueWrapper { + setupMwMock( { functions: [ 'msg', 'util' ] } ); return mount( SchemaDisplayHeader, { - props: { schema, canEditSchema }, + props: { schema, canEditSchema, canCreateSubject }, global: { mocks: { $i18n: createI18nMock() }, stubs: { @@ -55,4 +55,25 @@ describe( 'SchemaDisplayHeader', () => { expect( wrapper.emitted( 'edit' ) ).toHaveLength( 1 ); } ); + + it( 'links to the page-first creator for this schema when the user may create subjects', () => { + const wrapper = mountComponent( newSchema( { title: 'Company' } ), false, true ); + const link = wrapper.find( '.ext-neowiki-schema-display-header__create-subject' ); + + expect( link.attributes( 'href' ) ).toBe( '/wiki/Special:CreateSubject/Company' ); + expect( link.text() ).toContain( 'neowiki-schema-create-subjectCompany' ); + } ); + + it( 'places the creation link in the header content, not among the actions', () => { + const wrapper = mountComponent( newSchema( { title: 'Company' } ), true, true ); + + expect( wrapper.find( '.ext-neowiki-schema-display-header__content .ext-neowiki-schema-display-header__create-subject' ).exists() ).toBe( true ); + expect( wrapper.find( '.ext-neowiki-schema-display-header__actions .ext-neowiki-schema-display-header__create-subject' ).exists() ).toBe( false ); + } ); + + it( 'offers no creation link when the user may not create subjects', () => { + const wrapper = mountComponent( newSchema(), false, false ); + + expect( wrapper.find( '.ext-neowiki-schema-display-header__create-subject' ).exists() ).toBe( false ); + } ); } ); diff --git a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts index e7a046c31..b66fbe06b 100644 --- a/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts +++ b/resources/ext.neowiki/tests/components/SubjectCreator/SubjectCreatorDialog.spec.ts @@ -1,15 +1,18 @@ import { mount, VueWrapper, flushPromises } from '@vue/test-utils'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ref } from 'vue'; import SubjectCreatorDialog from '@/components/SubjectCreator/SubjectCreatorDialog.vue'; import SchemaPicker from '@/components/common/SchemaPicker.vue'; +import PagePicker from '@/components/common/PagePicker.vue'; import SchemaCreator from '@/components/SchemaCreator/SchemaCreator.vue'; import SummaryAction from '@/components/common/SummaryAction.vue'; import { createPinia, setActivePinia } from 'pinia'; import { useSubjectStore } from '@/stores/SubjectStore.ts'; import { useSchemaStore } from '@/stores/SchemaStore.ts'; import { createI18nMock, setupMwMock } from '../../VueTestHelpers.ts'; -import { newSchema } from '@/TestHelpers.ts'; +import { newSchema, newSubject } from '@/TestHelpers.ts'; +import { PageSubjects } from '@/domain/PageSubjects.ts'; +import type { SubjectRepository } from '@/domain/SubjectRepository.ts'; import { CdxDialog } from '@wikimedia/codex'; import CloseConfirmationDialog from '@/components/common/CloseConfirmationDialog.vue'; import SchemaAbandonmentDialog from '@/components/SubjectCreator/SchemaAbandonmentDialog.vue'; @@ -39,6 +42,12 @@ const NEW_SCHEMA_NAME = 'NewSchema'; vi.mock( '@/composables/useSchemaPermissions.ts' ); +interface Deferred { + promise: Promise; + resolve: ( value: T ) => void; + reject: ( error: unknown ) => void; +} + const SchemaPickerStub = { template: '
', emits: [ 'select' ], @@ -199,10 +208,10 @@ describe( 'SubjectCreatorDialog', () => { editorUnparseableInput = null; schemaCreatorUnparseableInput = null; reloadMock = vi.fn(); - vi.stubGlobal( 'location', { ...window.location, reload: reloadMock } ); + vi.stubGlobal( 'location', { href: '', reload: reloadMock } ); setupMwMock( { - functions: [ 'msg', 'notify', 'config', 'storage' ], + functions: [ 'msg', 'notify', 'config', 'storage', 'util' ], config: { wgArticleId: PAGE_ID, wgTitle: PAGE_TITLE, @@ -236,6 +245,10 @@ describe( 'SubjectCreatorDialog', () => { } ); } ); + afterEach( () => { + vi.unstubAllGlobals(); + } ); + it( 'renders the dialog closed by default', () => { const wrapper = mountComponent(); expect( wrapper.findComponent( CdxDialog ).props( 'open' ) ).toBe( false ); @@ -608,6 +621,396 @@ describe( 'SubjectCreatorDialog', () => { ); } ); + describe( 'with an initial schema', () => { + it( 'opens on the second step with the initial schema loaded', async () => { + const wrapper = mountComponent( {}, { initialSchemaName: SCHEMA_NAME } ); + + subjectStore.openSubjectCreator(); + await flushPromises(); + + expect( getSchemaMock ).toHaveBeenCalledWith( SCHEMA_NAME ); + expect( wrapper.findComponent( SchemaPicker ).exists() ).toBe( false ); + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( true ); + } ); + + it( 'falls back to the picker when the initial schema cannot be loaded', async () => { + getSchemaMock.mockRejectedValue( new Error( 'No such schema' ) ); + const wrapper = mountComponent( {}, { initialSchemaName: 'Missing' } ); + + subjectStore.openSubjectCreator(); + await flushPromises(); + + expect( wrapper.findComponent( SchemaPicker ).exists() ).toBe( true ); + expect( wrapper.findComponent( SubjectEditor ).exists() ).toBe( false ); + } ); + + it( 'leaves save unavailable until something is entered', async () => { + const wrapper = mountComponent( {}, { initialSchemaName: SCHEMA_NAME } ); + + subjectStore.openSubjectCreator(); + await flushPromises(); + + expect( wrapper.findComponent( SummaryAction ).props( 'saveDisabled' ) ).toBe( true ); + } ); + } ); + + describe( 'in page-first mode', () => { + const NEW_PAGE_ID = 99; + const EXISTING_PAGE_ID = 12; + const OTHER_PAGE_ID = 13; + const MAIN_ID = 's11111111111taa'; + let createMock: ReturnType; + let getPageSubjectsMock: ReturnType; + let repositorySpy: ReturnType; + + const PagePickerStub = { + template: '
', + props: [ 'excludedPageId', 'ariaLabel' ], + emits: [ 'update:selected' ], + }; + + // Renders the field's error so the page-creation failures below are visible as text. + const CdxFieldWithMessagesStub = { + template: '
{{ messages?.error }}
', + props: [ 'status', 'messages', 'optional' ], + }; + + const I18nSlotStub = { + template: '{{ messageKey }}', + props: [ 'messageKey' ], + }; + + function mountPageFirst( props: Record = {} ): VueWrapper { + return mountComponent( + { PagePicker: PagePickerStub, CdxField: CdxFieldWithMessagesStub, I18nSlot: I18nSlotStub }, + { choosePage: true, ...props }, + ); + } + + async function pickSchema( wrapper: VueWrapper ): Promise { + subjectStore.openSubjectCreator(); + await flushPromises(); + await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); + await flushPromises(); + } + + async function pickPage( wrapper: VueWrapper, choice: unknown ): Promise { + wrapper.findComponent( PagePicker ).vm.$emit( 'update:selected', choice ); + await flushPromises(); + } + + async function save( wrapper: VueWrapper, summary = '' ): Promise { + await wrapper.findComponent( SummaryAction ).vm.$emit( 'save', summary ); + await flushPromises(); + } + + function deferred(): Deferred { + let resolve!: ( value: T ) => void; + let reject!: ( error: unknown ) => void; + const promise = new Promise( ( resolvePromise, rejectPromise ) => { + resolve = resolvePromise; + reject = rejectPromise; + } ); + + return { promise, resolve, reject }; + } + + function pageWithMainSubject( name: string ): unknown { + return { + pageSubjects: new PageSubjects( EXISTING_PAGE_ID, new SubjectId( MAIN_ID ), [ + newSubject( { id: MAIN_ID, label: name } ), + ] ), + referencedSubjects: [], + schemas: [], + }; + } + + beforeEach( () => { + createMock = vi.fn().mockResolvedValue( { result: 'Success', pageid: NEW_PAGE_ID } ); + ( mw as any ).Api = vi.fn( function ( this: { create: typeof createMock } ) { + this.create = createMock; + } ); + + getPageSubjectsMock = vi.fn().mockResolvedValue( { + pageSubjects: new PageSubjects( EXISTING_PAGE_ID, null, [] ), + referencedSubjects: [], + schemas: [], + } ); + repositorySpy = vi.spyOn( NeoWikiExtension.getInstance(), 'getSubjectRepository' ).mockReturnValue( + { getPageSubjects: getPageSubjectsMock } as unknown as SubjectRepository, + ); + } ); + + afterEach( () => { + repositorySpy.mockRestore(); + } ); + + it( 'asks for a page in the second step', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + + expect( wrapper.findComponent( PagePicker ).exists() ).toBe( true ); + } ); + + it( 'asks for no page when the dialog belongs to a page', async () => { + const wrapper = mountComponent( { PagePicker: PagePickerStub } ); + await pickSchema( wrapper ); + + expect( wrapper.findComponent( PagePicker ).exists() ).toBe( false ); + } ); + + it( 'keeps save unavailable until a page is chosen', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + + expect( wrapper.findComponent( SummaryAction ).props( 'saveDisabled' ) ).toBe( true ); + + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + expect( wrapper.findComponent( SummaryAction ).props( 'saveDisabled' ) ).toBe( false ); + } ); + + it( 'creates the page and makes the subject its main subject', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper, 'why' ); + + expect( createMock ).toHaveBeenCalledWith( 'New Person', { summary: 'why' }, '' ); + expect( subjectStore.createMainSubject ).toHaveBeenCalledWith( + NEW_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), 'why', + ); + } ); + + it( 'navigates to the created page rather than reloading', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + + expect( mw.storage.session.set ).toHaveBeenCalledWith( 'neowiki-subject-creator-success', '1' ); + expect( location.href ).toBe( '/wiki/New Person' ); + expect( reloadMock ).not.toHaveBeenCalled(); + } ); + + it( 'gives the page a summary of its own when none was entered', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper, '' ); + + expect( createMock ).toHaveBeenCalledWith( + 'New Person', { summary: 'neowiki-subject-creator-create-page-summary' }, '', + ); + } ); + + it( 'creates no page when the chosen page exists', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + await save( wrapper ); + + expect( createMock ).not.toHaveBeenCalled(); + expect( subjectStore.createMainSubject ).toHaveBeenCalledWith( + EXISTING_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + } ); + + it( 'adds the subject alongside an existing main subject and says so', async () => { + getPageSubjectsMock.mockResolvedValue( pageWithMainSubject( 'ACME Inc' ) ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + expect( wrapper.text() ).toContain( 'neowiki-subject-creator-page-has-main-subject' ); + expect( wrapper.text() ).toContain( 'ACME Inc' ); + + await save( wrapper ); + + expect( subjectStore.createChildSubject ).toHaveBeenCalledWith( + EXISTING_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + expect( subjectStore.createMainSubject ).not.toHaveBeenCalled(); + } ); + + it( 'previews the chosen page title as the label placeholder', async () => { + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + expect( wrapper.find( '.cdx-text-input-stub' ).attributes( 'placeholder' ) ).toBe( 'New Person' ); + } ); + + it( 'names the clash when the page it was going to create already exists', async () => { + createMock.mockRejectedValue( { code: 'articleexists' } ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'Taken' } ); + + await save( wrapper ); + + expect( wrapper.text() ).toContain( 'neowiki-subject-creator-page-takenTaken' ); + expect( subjectStore.createMainSubject ).not.toHaveBeenCalled(); + } ); + + it( 'retries onto the page it already created rather than creating it twice', async () => { + ( subjectStore.createMainSubject as any ).mockRejectedValueOnce( new Error( 'Graph store down' ) ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + await save( wrapper ); + + expect( createMock ).toHaveBeenCalledTimes( 1 ); + expect( subjectStore.createMainSubject ).toHaveBeenLastCalledWith( + NEW_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + } ); + + it( 'keeps the page it was saving onto when the picker changes mid-save', async () => { + const subjectWrite = deferred(); + ( subjectStore.createMainSubject as any ).mockReturnValue( subjectWrite.promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + await pickPage( wrapper, null ); + subjectWrite.resolve( new SubjectId( MAIN_ID ) ); + await flushPromises(); + + expect( location.href ).toBe( '/wiki/New Person' ); + expect( reloadMock ).not.toHaveBeenCalled(); + expect( mw.notify ).not.toHaveBeenCalled(); + } ); + + it( 'creates the subject on the page that was chosen when the save started', async () => { + // The pre-save dry-run is the round trip that leaves the picker live between the save + // starting and the page it writes to being settled. + const dryRun = deferred(); + ( subjectStore.validateSubject as any ).mockReturnValue( dryRun.promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + await pickPage( wrapper, null ); + dryRun.resolve( [] ); + await flushPromises(); + + expect( subjectStore.createMainSubject ).toHaveBeenCalledWith( + NEW_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + } ); + + it( 'keeps the tier decided when the save started if the picker changes mid-save', async () => { + const pageCreation = deferred<{ result: string; pageid: number }>(); + createMock.mockReturnValue( pageCreation.promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + + getPageSubjectsMock.mockResolvedValue( pageWithMainSubject( 'ACME Inc' ) ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + pageCreation.resolve( { result: 'Success', pageid: NEW_PAGE_ID } ); + await flushPromises(); + + expect( subjectStore.createMainSubject ).toHaveBeenCalledWith( + NEW_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + expect( subjectStore.createChildSubject ).not.toHaveBeenCalled(); + } ); + + it( 'ignores a second save while one is in flight', async () => { + createMock.mockReturnValue( deferred().promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + + await save( wrapper ); + await save( wrapper ); + + expect( createMock ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'blocks saving when the chosen page could not be read', async () => { + getPageSubjectsMock.mockRejectedValue( new Error( 'Graph store unavailable' ) ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + expect( wrapper.findComponent( SummaryAction ).props( 'saveDisabled' ) ).toBe( true ); + + await save( wrapper ); + + expect( subjectStore.createMainSubject ).not.toHaveBeenCalled(); + } ); + + it( 'says the chosen page could not be read', async () => { + getPageSubjectsMock.mockRejectedValue( new Error( 'Graph store unavailable' ) ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + expect( wrapper.text() ).toContain( 'neowiki-subject-creator-page-read-error' ); + } ); + + it( 'ignores the main-subject answer for a page the user has moved off', async () => { + const slowRead = deferred(); + getPageSubjectsMock.mockReturnValueOnce( slowRead.promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + await pickPage( wrapper, { pageId: OTHER_PAGE_ID, title: 'Other Page' } ); + slowRead.resolve( pageWithMainSubject( 'ACME Inc' ) ); + await flushPromises(); + + expect( wrapper.text() ).not.toContain( 'ACME Inc' ); + + await save( wrapper ); + + expect( subjectStore.createMainSubject ).toHaveBeenCalledWith( + OTHER_PAGE_ID, null, SCHEMA_NAME, expect.any( StatementList ), undefined, + ); + } ); + + it( 'ignores a read failure for a page the user has moved off', async () => { + const slowRead = deferred(); + getPageSubjectsMock.mockReturnValueOnce( slowRead.promise ); + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + await pickPage( wrapper, { pageId: EXISTING_PAGE_ID, title: 'ACME Inc' } ); + + await pickPage( wrapper, { pageId: null, title: 'New Person' } ); + slowRead.reject( new Error( 'Graph store unavailable' ) ); + await flushPromises(); + + expect( wrapper.text() ).not.toContain( 'neowiki-subject-creator-page-read-error' ); + expect( wrapper.findComponent( SummaryAction ).props( 'saveDisabled' ) ).toBe( false ); + } ); + + it( 'requests no edit notices in page-first mode', async () => { + const getNotices = vi.fn().mockResolvedValue( [] ); + const noticeRepositorySpy = vi.spyOn( NeoWikiExtension.getInstance(), 'getEditNoticeRepository' ) + .mockReturnValue( { getNotices } as never ); + + const wrapper = mountPageFirst(); + await pickSchema( wrapper ); + + expect( getNotices ).not.toHaveBeenCalled(); + + noticeRepositorySpy.mockRestore(); + } ); + } ); + describe( 'Unparseable field input', () => { async function pickSchema( wrapper: VueWrapper ): Promise { await wrapper.findComponent( SchemaPicker ).vm.$emit( 'select', SCHEMA_NAME ); diff --git a/resources/ext.neowiki/tests/persistence/RightsBasedSubjectPermissionHintsTest.unit.spec.ts b/resources/ext.neowiki/tests/persistence/RightsBasedSubjectPermissionHintsTest.unit.spec.ts index 007df7968..46278aa78 100644 --- a/resources/ext.neowiki/tests/persistence/RightsBasedSubjectPermissionHintsTest.unit.spec.ts +++ b/resources/ext.neowiki/tests/persistence/RightsBasedSubjectPermissionHintsTest.unit.spec.ts @@ -75,4 +75,16 @@ describe( 'Rights Based Subject Permission Hints', async () => { expect( await hints.canCreateMainSubject() ).toBe( true ); } ); + it( 'can create a subject page with the createpage and edit rights', async () => { + expect( await newHints( [ 'createpage', 'edit' ] ).canCreateSubjectPage() ).toBe( true ); + } ); + + it( 'cannot create a subject page without the createpage right', async () => { + expect( await newHints( [ 'edit' ] ).canCreateSubjectPage() ).toBe( false ); + } ); + + it( 'cannot create a subject page without the edit right', async () => { + expect( await newHints( [ 'createpage' ] ).canCreateSubjectPage() ).toBe( false ); + } ); + } ); diff --git a/resources/ext.neowiki/tests/persistence/createEmptyPage.spec.ts b/resources/ext.neowiki/tests/persistence/createEmptyPage.spec.ts new file mode 100644 index 000000000..f00053575 --- /dev/null +++ b/resources/ext.neowiki/tests/persistence/createEmptyPage.spec.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createEmptyPage, PageCreationError } from '@/persistence/createEmptyPage.ts'; + +describe( 'createEmptyPage', () => { + let createMock: ReturnType; + + beforeEach( () => { + createMock = vi.fn().mockResolvedValue( { result: 'Success', pageid: 99 } ); + vi.stubGlobal( 'mw', { + Api: vi.fn( function ( this: { create: typeof createMock } ) { + this.create = createMock; + } ), + } ); + } ); + + afterEach( () => { + vi.unstubAllGlobals(); + } ); + + it( 'creates the page empty under the given summary and answers with its id', async () => { + expect( await createEmptyPage( 'New Page', 'why' ) ).toBe( 99 ); + expect( createMock ).toHaveBeenCalledWith( 'New Page', { summary: 'why' }, '' ); + } ); + + it( 'reports a taken title as such', async () => { + createMock.mockRejectedValue( { code: 'articleexists' } ); + + await expect( createEmptyPage( 'Taken', '' ) ).rejects.toSatisfy( + ( error: PageCreationError ) => error.titleTaken() && error.title === 'Taken', + ); + } ); + + it( 'refuses a response that did not report success', async () => { + createMock.mockResolvedValue( { result: 'Failure' } ); + + await expect( createEmptyPage( 'New Page', '' ) ).rejects.toThrow( PageCreationError ); + } ); + + it( 'reports any other refusal without calling the title taken', async () => { + createMock.mockRejectedValue( 'invalidtitle' ); + + await expect( createEmptyPage( 'Bad|Title', '' ) ).rejects.toSatisfy( + ( error: PageCreationError ) => !error.titleTaken() && error.code === 'invalidtitle', + ); + } ); +} ); diff --git a/src/EntryPoints/NeoWikiHooks.php b/src/EntryPoints/NeoWikiHooks.php index 616c98144..7dc57f5a9 100644 --- a/src/EntryPoints/NeoWikiHooks.php +++ b/src/EntryPoints/NeoWikiHooks.php @@ -496,7 +496,7 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi ); if ( $title->getNamespace() === NeoWikiExtension::NS_SCHEMA ) { - $neoWikiTools[] = self::allPagesLink( + $neoWikiTools[] = self::specialPageLink( $skin, specialPage: 'Schemas', message: 'neowiki-schema-sidebar-all-schemas', @@ -505,7 +505,7 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi } if ( $title->getNamespace() === NeoWikiExtension::NS_LAYOUT ) { - $neoWikiTools[] = self::allPagesLink( + $neoWikiTools[] = self::specialPageLink( $skin, specialPage: 'Layouts', message: 'neowiki-layout-sidebar-all-layouts', @@ -514,7 +514,7 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi } if ( $title->getNamespace() === NeoWikiExtension::NS_MAPPING ) { - $neoWikiTools[] = self::allPagesLink( + $neoWikiTools[] = self::specialPageLink( $skin, specialPage: 'Mappings', message: 'neowiki-mapping-sidebar-all-mappings', @@ -522,6 +522,15 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi ); } + if ( $skin->getAuthority()->isAllowedAll( 'createpage', 'edit' ) ) { + $neoWikiTools[] = self::specialPageLink( + $skin, + specialPage: 'CreateSubject', + message: 'neowiki-sidebar-create-subject', + linkId: 't-neowiki-create-subject-page' + ); + } + if ( $neoWikiTools !== [] ) { // The section array key is used by MediaWiki as the message key for // the section heading, so it must match an existing message name. @@ -532,7 +541,7 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi /** * @return array */ - private static function allPagesLink( + private static function specialPageLink( Skin $skin, string $specialPage, string $message, diff --git a/src/EntryPoints/SpecialPages/EnforcesRestriction.php b/src/EntryPoints/SpecialPages/EnforcesRestriction.php new file mode 100644 index 000000000..630d23887 --- /dev/null +++ b/src/EntryPoints/SpecialPages/EnforcesRestriction.php @@ -0,0 +1,35 @@ +getPermissionManager() + ->userHasRight( $user, $this->getRestriction() ); + } + + public function isRestricted(): bool { + return !MediaWikiServices::getInstance() + ->getGroupPermissionsLookup() + ->groupHasPermission( '*', $this->getRestriction() ); + } + + protected function displayRestrictionError(): never { + throw new PermissionsError( $this->getRestriction() ); + } + +} diff --git a/src/EntryPoints/SpecialPages/SpecialCreateSubject.php b/src/EntryPoints/SpecialPages/SpecialCreateSubject.php new file mode 100644 index 000000000..d83524fdd --- /dev/null +++ b/src/EntryPoints/SpecialPages/SpecialCreateSubject.php @@ -0,0 +1,59 @@ +getAuthority()->isAllowed( 'createpage' ) ) { + throw new PermissionsError( 'createpage' ); + } + + NeoWikiExtension::getInstance()->newFrontendModuleLoader()->load( $this->getOutput(), $this->getSkin() ); + + $attributes = [ 'id' => 'ext-neowiki-create-subject' ]; + + $schemaTitle = MediaWikiServices::getInstance()->getTitleFactory() + ->newFromText( $subPage ?? '', NeoWikiExtension::NS_SCHEMA ); + + if ( $schemaTitle !== null ) { + $attributes['data-mw-neowiki-schema'] = $schemaTitle->getText(); + } + + $this->getOutput()->addHTML( Html::element( 'div', $attributes ) ); + } + + public function getRestriction(): string { + return 'edit'; + } + + public function getGroupName(): string { + return 'neowiki'; + } + + public function getDescription(): Message { + return $this->msg( 'neowiki-special-createsubject' ); + } + +} diff --git a/src/EntryPoints/SpecialPages/SpecialGraphStores.php b/src/EntryPoints/SpecialPages/SpecialGraphStores.php index 4f8df25e9..ebcef0168 100644 --- a/src/EntryPoints/SpecialPages/SpecialGraphStores.php +++ b/src/EntryPoints/SpecialPages/SpecialGraphStores.php @@ -6,12 +6,9 @@ use MediaWiki\Html\Html; use MediaWiki\Logger\LoggerFactory; -use MediaWiki\MediaWikiServices; use MediaWiki\Message\Message; use MediaWiki\Session\CsrfTokenSet; use MediaWiki\SpecialPage\SpecialPage; -use MediaWiki\User\User; -use PermissionsError; use ProfessionalWiki\NeoWiki\Application\GraphRebuild\GraphRebuildCoordinator; use ProfessionalWiki\NeoWiki\Application\GraphRebuild\GraphStoreStatus; use ProfessionalWiki\NeoWiki\Application\GraphRebuild\NothingToCancelException; @@ -34,6 +31,8 @@ */ class SpecialGraphStores extends SpecialPage { + use EnforcesRestriction; + private const ACTION_FIELD = 'nwAction'; private const STORE_FIELD = 'nwStore'; private const REBUILD_ACTION = 'rebuild'; @@ -65,28 +64,6 @@ public function getRestriction(): string { return NeoWikiExtension::ADMIN_RIGHT; } - /** - * MediaWiki 1.46 made getRestriction() the one place a special page names the right it needs, and - * pointed enforcement, listing and the denial page at it. Up to 1.45 those three instead read the - * property the deprecated constructor parameter set, so each is restated here against - * getRestriction(), leaving every supported version taking the right from one place. - */ - public function userCanExecute( User $user ): bool { - return MediaWikiServices::getInstance() - ->getPermissionManager() - ->userHasRight( $user, $this->getRestriction() ); - } - - public function isRestricted(): bool { - return !MediaWikiServices::getInstance() - ->getGroupPermissionsLookup() - ->groupHasPermission( '*', $this->getRestriction() ); - } - - protected function displayRestrictionError(): never { - throw new PermissionsError( $this->getRestriction() ); - } - /** * @param ?string $subPage */ diff --git a/tests/phpunit/EntryPoints/NeoWikiSidebarLinkTest.php b/tests/phpunit/EntryPoints/NeoWikiSidebarLinkTest.php index e6a88ca48..5a5a858c4 100644 --- a/tests/phpunit/EntryPoints/NeoWikiSidebarLinkTest.php +++ b/tests/phpunit/EntryPoints/NeoWikiSidebarLinkTest.php @@ -6,6 +6,7 @@ use MediaWiki\Context\RequestContext; use MediaWiki\Title\Title; +use MediaWiki\User\User; use ProfessionalWiki\NeoWiki\EntryPoints\NeoWikiHooks; use ProfessionalWiki\NeoWiki\NeoWikiExtension; use ProfessionalWiki\NeoWiki\Tests\NeoWikiIntegrationTestCase; @@ -56,6 +57,38 @@ public function testAddsNoAllPagesLinkOutsideNeoWikiNamespaces(): void { $this->assertNull( $this->findLinkById( $sidebar[self::NEOWIKI_SECTION] ?? [], 't-neowiki-mappings' ) ); } + public function testCreateSubjectLinkIsPlacedInTheNeoWikiSection(): void { + $link = $this->findLinkById( $this->neoWikiSectionOnAnOrdinaryPage(), 't-neowiki-create-subject-page' ); + + $this->assertNotNull( $link, 'Expected the create-subject link in the NeoWiki sidebar section.' ); + $this->assertSame( 'Create subject', $link['text'] ); + $this->assertStringContainsString( 'CreateSubject', $link['href'] ); + } + + public function testCreateSubjectLinkIsListedAfterThePageTools(): void { + $section = $this->neoWikiSectionOnAnOrdinaryPage(); + + $this->assertSame( 't-neowiki-create-subject-page', end( $section )['id'] ); + } + + public function testCreateSubjectLinkIsAbsentForAUserWhoMayNotCreatePages(): void { + $this->setGroupPermissions( '*', 'createpage', false ); + $this->setGroupPermissions( 'user', 'createpage', false ); + + $this->assertNull( + $this->findLinkById( $this->neoWikiSectionOnAnOrdinaryPage(), 't-neowiki-create-subject-page' ) + ); + } + + public function testCreateSubjectLinkIsAbsentForAUserWhoMayNotEdit(): void { + $this->setGroupPermissions( '*', 'edit', false ); + $this->setGroupPermissions( 'user', 'edit', false ); + + $this->assertNull( + $this->findLinkById( $this->neoWikiSectionOnAnOrdinaryPage(), 't-neowiki-create-subject-page' ) + ); + } + private function assertAllPagesLinkInNeoWikiSection( int $namespace, string $linkId, @@ -78,10 +111,20 @@ private function assertAllPagesLinkInNeoWikiSection( ); } - private function buildSidebar( Title $title ): array { + private function neoWikiSectionOnAnOrdinaryPage(): array { + $sidebar = $this->buildSidebar( Title::makeTitle( NS_MAIN, 'Ordinary Page' ), $this->getTestUser()->getUser() ); + + return $sidebar[self::NEOWIKI_SECTION] ?? []; + } + + private function buildSidebar( Title $title, ?User $user = null ): array { $context = new RequestContext(); $context->setTitle( $title ); + if ( $user !== null ) { + $context->setUser( $user ); + } + $sidebar = []; NeoWikiHooks::onSidebarBeforeOutput( $context->getSkin(), $sidebar ); diff --git a/tests/phpunit/EntryPoints/SpecialPages/SpecialCreateSubjectTest.php b/tests/phpunit/EntryPoints/SpecialPages/SpecialCreateSubjectTest.php new file mode 100644 index 000000000..d757c8d93 --- /dev/null +++ b/tests/phpunit/EntryPoints/SpecialPages/SpecialCreateSubjectTest.php @@ -0,0 +1,69 @@ +executeSpecialPage( '', null, null, $this->getTestUser()->getUser() ); + + $this->assertStringContainsString( 'id="ext-neowiki-create-subject"', $output ); + $this->assertStringNotContainsString( 'data-mw-neowiki-schema', $output ); + } + + public function testSubpageNamesTheSchemaToPin(): void { + /** @var string $output */ + [ $output ] = $this->executeSpecialPage( 'Person', null, null, $this->getTestUser()->getUser() ); + + $this->assertStringContainsString( 'data-mw-neowiki-schema="Person"', $output ); + } + + public function testSubpageUnderscoresBecomeSpacesInTheSchemaName(): void { + /** @var string $output */ + [ $output ] = $this->executeSpecialPage( 'Legal_Entity', null, null, $this->getTestUser()->getUser() ); + + $this->assertStringContainsString( 'data-mw-neowiki-schema="Legal Entity"', $output ); + } + + public function testAnUnparseableSubpagePinsNoSchema(): void { + /** @var string $output */ + [ $output ] = $this->executeSpecialPage( '<', null, null, $this->getTestUser()->getUser() ); + + $this->assertStringNotContainsString( 'data-mw-neowiki-schema', $output ); + } + + public function testAUserWithoutTheEditRightIsRefused(): void { + $this->setGroupPermissions( '*', 'edit', false ); + $this->setGroupPermissions( 'user', 'edit', false ); + + $this->expectException( PermissionsError::class ); + + $this->executeSpecialPage( '', null, null, $this->getTestUser()->getUser() ); + } + + public function testAUserWithoutTheCreatepageRightIsRefused(): void { + $this->setGroupPermissions( '*', 'createpage', false ); + $this->setGroupPermissions( 'user', 'createpage', false ); + + $this->expectException( PermissionsError::class ); + + $this->executeSpecialPage( '', null, null, $this->getTestUser()->getUser() ); + } + +} diff --git a/tests/phpunit/EntryPoints/SpecialPages/SpecialGraphStoresTest.php b/tests/phpunit/EntryPoints/SpecialPages/SpecialGraphStoresTest.php index bac8467b2..ee9c1f3ff 100644 --- a/tests/phpunit/EntryPoints/SpecialPages/SpecialGraphStoresTest.php +++ b/tests/phpunit/EntryPoints/SpecialPages/SpecialGraphStoresTest.php @@ -29,6 +29,7 @@ * what that message happens to say in English. * * @covers \ProfessionalWiki\NeoWiki\EntryPoints\SpecialPages\SpecialGraphStores + * @covers \ProfessionalWiki\NeoWiki\EntryPoints\SpecialPages\EnforcesRestriction * @group Database */ class SpecialGraphStoresTest extends SpecialPageTestBase {