wikimedia/mediawiki-extensions-Wikibase

View on GitHub
client/data-bridge/src/store/actions.ts

Summary

Maintainability
D
3 days
Test Coverage
import { DataValue } from '@wmde/wikibase-datamodel-types';
import ApiErrors from '@/data-access/error/ApiErrors';
import SavingError from '@/data-access/error/SavingError';
import { Store } from 'vuex';
import Application, { InitializedApplicationState, SavingState } from '@/store/Application';
import ApplicationStatus from '@/definitions/ApplicationStatus';
import AppInformation from '@/definitions/AppInformation';
import EditDecision from '@/definitions/EditDecision';
import { NS_ENTITY, NS_STATEMENTS } from '@/store/namespaces';
import { MainSnakPath } from '@/store/statements/MainSnakPath';
import ApplicationError, { ErrorTypes } from '@/definitions/ApplicationError';
import { Actions, Context } from 'vuex-smart-module';
import { RootGetters } from '@/store/getters';
import { RootMutations } from '@/store/mutations';
import Term from '@/datamodel/Term';
import { entityModule } from './entity';
import { statementModule } from '@/store/statements';
import { ApiError, ApiBadtokenError } from '@/definitions/data-access/Api';
import { MissingPermissionsError } from '@/definitions/data-access/BridgePermissionsRepository';
import { WikibaseRepoConfiguration } from '@/definitions/data-access/WikibaseRepoConfigRepository';
import ServiceContainer from '@/services/ServiceContainer';
import statementMutationFactory from '@/change-op/statement-mutation/statementMutationFactory';
import clone from '@/store/clone';
import StatementMutationStrategy from '@/change-op/statement-mutation/strategies/StatementMutationStrategy';
import { TempUserConfiguration } from '@/definitions/data-access/TempUserConfigRepository';

export class RootActions extends Actions<
Application,
RootGetters,
RootMutations,
RootActions
> {
    private store!: { $services: ServiceContainer };
    private entityModule!: Context<typeof entityModule>;
    private statementModule!: Context<typeof statementModule>;
    private statementMutationFactory!: ( editDecision: EditDecision ) => StatementMutationStrategy;

    public $init( store: Store<Application> ): void {
        this.store = store;
        this.entityModule = entityModule.context( store );
        this.statementModule = statementModule.context( store );
        this.statementMutationFactory = statementMutationFactory;
    }

    public relaunchBridge( information: AppInformation ): Promise<unknown> {
        this.commit( 'reset' );
        this.entityModule.commit( 'reset' );
        this.statementModule.commit( 'reset' );

        return this.dispatch( 'initBridge', information );
    }

    public initBridge(
        information: AppInformation,
    ): Promise<void> {
        this.commit( 'setEditFlow', information.editFlow );
        this.commit( 'setPropertyPointer', information.propertyId );
        this.commit( 'setEntityTitle', information.entityTitle );
        this.commit( 'setOriginalHref', information.originalHref );
        this.commit( 'setPageTitle', information.pageTitle );
        this.commit( 'setPageUrl', information.pageUrl );
        this.commit( 'setClientConfig', information.client );
        this.commit( 'setShowWarningAnonymousEdit', information.userName === null );

        this.dispatch( 'requestAndSetTargetLabel', information.propertyId );

        const hasCentralauthBadtokenError = ( error: unknown ): boolean => {
            if ( !( error instanceof ApiErrors ) ) {
                return false;
            }
            return error.errors.some( ( apiError: ApiError ): boolean => {
                if ( apiError.code !== 'badtoken' ) {
                    return false;
                }
                return ( apiError as ApiBadtokenError ).params[ 0 ] === 'apierror-centralauth-badtoken';
            } );
        };

        const getRemoteData = (): Promise<[
            WikibaseRepoConfiguration,
            TempUserConfiguration,
            MissingPermissionsError[],
            string,
            unknown
        ]> => {
            return Promise.all( [
                this.store.$services.get( 'wikibaseRepoConfigRepository' ).getRepoConfiguration(),
                this.store.$services.get( 'tempUserConfigRepository' ).getTempUserConfiguration(),
                this.store.$services.get( 'editAuthorizationChecker' ).canUseBridgeForItemAndPage(
                    information.entityTitle,
                    information.pageTitle,
                ),
                this.store.$services.get( 'propertyDatatypeRepository' ).getDataType( information.propertyId ),
                this.entityModule.dispatch(
                    'entityInit',
                    { entity: information.entityId },
                ),
            ] );
        };

        return getRemoteData()
            .catch( ( error ) => {
                if ( !hasCentralauthBadtokenError( error ) ) {
                    throw error;
                }
                // retry once (T295064)
                return getRemoteData()
                    .then( ( data ) => {
                        this.store.$services.get( 'tracker' ).trackRecoveredError( ErrorTypes.CENTRALAUTH_BADTOKEN );
                        return data;
                    } );
            } )
            .then(
                ( results ) => this.dispatch( 'initBridgeWithRemoteData', { results } ),
                ( error ) => {
                    const type = hasCentralauthBadtokenError( error )
                        ? ErrorTypes.CENTRALAUTH_BADTOKEN
                        : ErrorTypes.INITIALIZATION_ERROR;
                    this.commit( 'addApplicationErrors', [ { type, info: error } ] );
                    throw error;
                },
            ).then( () => {
                this.commit(
                    'setApplicationStatus',
                    ApplicationStatus.READY,
                );
            } );
    }

    public async initBridgeWithRemoteData( {
        results: [
            wikibaseRepoConfiguration,
            tempUserConfiguration,
            permissionErrors,
            dataType,
            _entityInit,
        ],
    }: {
        results: [
            WikibaseRepoConfiguration,
            TempUserConfiguration,
            readonly MissingPermissionsError[],
            string,
            unknown
        ];
    } ): Promise<unknown> {
        if ( permissionErrors.length ) {
            this.commit( 'addApplicationErrors', permissionErrors );
            return;
        }

        this.store.$services.get( 'tracker' ).trackPropertyDatatype( dataType );

        try {
            await this.dispatch( 'renderReferences' );
        } catch ( e ) {
            this.store.$services.get( 'tracker' ).trackError( 'render_references' );
        }

        this.commit( 'setRepoConfig', wikibaseRepoConfiguration );
        this.commit( 'setTempUserConfig', tempUserConfiguration );

        return this.dispatch( 'postEntityLoad' );
    }

    public async renderReferences(): Promise<void> {
        const renderedReferences = await this.store.$services.get( 'referencesRenderingRepository' )
            .getRenderedReferences( this.getters.targetReferences );
        this.commit( 'setRenderedTargetReferences', renderedReferences );
    }

    public async postEntityLoad(): Promise<void> {
        const state = this.state as InitializedApplicationState;

        const path = new MainSnakPath(
            state[ NS_ENTITY ].id,
            state.targetProperty,
            0,
        );

        await this.dispatch( 'validateEntityState', path );
        if ( this.getters.applicationStatus !== ApplicationStatus.ERROR ) {
            this.commit(
                'setTargetValue',
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                path.resolveSnakInStatement( state[ NS_STATEMENTS ] )!.datavalue!,
            );
        }
    }

    public requestAndSetTargetLabel( propertyId: string ): Promise<void> {
        return this.store.$services.get( 'entityLabelRepository' ).getLabel( propertyId )
            .then( ( label: Term ) => {
                this.commit( 'setTargetLabel', label );
            }, ( _error: Error ) => {
                // TODO: handling on failed label loading, which is not a bocking error for now
            } );
    }

    public validateEntityState(
        path: MainSnakPath,
    ): Promise<unknown> {
        if ( !this.statementModule.getters.propertyExists( path ) ) {
            this.commit( 'addApplicationErrors', [ { type: ErrorTypes.INVALID_ENTITY_STATE_ERROR } ] );
            return Promise.resolve();
        }

        return this.dispatch( 'validateBridgeApplicability', path );
    }

    public validateBridgeApplicability(
        path: MainSnakPath,
    ): Promise<unknown> {
        if ( this.state.applicationStatus === ApplicationStatus.SAVED ) {
            // saving edits can transition us from applicable to inapplicable states, but that should not be an error
            return Promise.resolve();
        }

        if ( this.statementModule.getters.isStatementGroupAmbiguous( path ) ) {
            return this.dispatch( 'addError', [ { type: ErrorTypes.UNSUPPORTED_AMBIGUOUS_STATEMENT } ] );
        }

        if ( this.statementModule.getters.rank( path ) === 'deprecated' ) {
            return this.dispatch( 'addError', [ { type: ErrorTypes.UNSUPPORTED_DEPRECATED_STATEMENT } ] );
        }

        const snakType = this.statementModule.getters.snakType( path );
        if ( snakType === null ) {
            throw new Error( 'If snak type is missing, there should have been an error earlier' );
        }
        if ( snakType !== 'value' ) {
            const error: ApplicationError = {
                type: ErrorTypes.UNSUPPORTED_SNAK_TYPE,
                info: { snakType },
            };
            return this.dispatch( 'addError', [ error ] );
        }

        const datatype = this.statementModule.getters.dataType( path );
        if ( datatype === null ) {
            throw new Error( 'If snak is missing, there should have been an error earlier' );
        }
        if ( datatype !== 'string' ) {
            const error: ApplicationError = {
                type: ErrorTypes.UNSUPPORTED_DATATYPE,
                info: {
                    unsupportedDatatype: datatype,
                },
            };
            return this.dispatch( 'addError', [ error ] );
        }

        if ( this.statementModule.getters.dataValueType( path ) !== 'string' ) {
            return this.dispatch( 'addError', [ { type: ErrorTypes.UNSUPPORTED_DATAVALUE_TYPE } ] );
        }

        return Promise.resolve();
    }

    public setTargetValue(
        dataValue: DataValue,
    ): Promise<void> {
        if ( this.state.applicationStatus !== ApplicationStatus.READY ) {
            this.commit( 'addApplicationErrors', [ {
                type: ErrorTypes.APPLICATION_LOGIC_ERROR,
                info: { stack: ( new Error() ).stack },
            } ] );
            return Promise.reject( null );
        }

        this.commit( 'setTargetValue', dataValue );

        return Promise.resolve();
    }

    public async saveBridge(): Promise<unknown> {
        if ( this.state.applicationStatus !== ApplicationStatus.READY ) {
            this.commit( 'addApplicationErrors', [ {
                type: ErrorTypes.APPLICATION_LOGIC_ERROR,
                info: { stack: ( new Error() ).stack },
            } ] );
            return Promise.reject( null );
        }
        this.commit(
            'setApplicationStatus',
            ApplicationStatus.SAVING,
        );
        const state = this.state as SavingState;
        const entityId = state[ NS_ENTITY ].id;
        const path = new MainSnakPath(
            entityId,
            state.targetProperty,
            0,
        );

        let statements;
        try {
            statements = this.statementMutationFactory( state.editDecision )
                .apply(
                    state.targetValue,
                    path,
                    clone( state[ NS_STATEMENTS ] ),
                );
        } catch ( error ) {
            this.commit( 'addApplicationErrors', [ {
                type: ErrorTypes.APPLICATION_LOGIC_ERROR,
                info: error,
            } as ApplicationError ] );
            throw error;
        }

        return this.entityModule.dispatch( 'entitySave', {
            statements: statements[ entityId ],
            assertUser: this.state.assertUserWhenSaving,
        } )
            .catch( ( error: Error ) => {
                if ( error instanceof SavingError
                    && error.errors.some( ( savingError ) => savingError.type === ErrorTypes.EDIT_CONFLICT ) ) {
                    this.dispatch( 'purgeTargetPage' );
                }
                if ( error instanceof SavingError ) {
                    this.commit( 'addApplicationErrors', error.errors );
                } else {
                    this.commit( 'addApplicationErrors', [ { type: ErrorTypes.SAVING_FAILED, info: error } ] );
                }
                throw error;
            } )
            .then( () => {
                return this.dispatch( 'purgeTargetPage' );
            } )
            .then( () => {
                this.commit(
                    'setApplicationStatus',
                    ApplicationStatus.SAVED,
                );
                return this.dispatch( 'postEntityLoad' );
            } );
    }

    public purgeTargetPage(): Promise<void> {
        return this.store.$services.get( 'purgeTitles' )
            .purge( [ this.state.pageTitle ] )
            .catch( () => {
                // we don't want to stop normal operation in that case
                this.store.$services.get( 'tracker' ).trackError( 'purge' );
            } );
    }

    public async retrySave(): Promise<unknown> {
        await this.dispatch( 'goBackFromErrorToReady' );
        return this.dispatch( 'saveBridge' );
    }

    public goBackFromErrorToReady(): Promise<void> {
        this.commit( 'clearApplicationErrors' );
        this.commit( 'setApplicationStatus', ApplicationStatus.READY );
        return Promise.resolve();
    }

    public addError(
        errors: readonly ApplicationError[],
    ): Promise<void> {
        this.commit( 'addApplicationErrors', errors );
        return Promise.resolve();
    }

    public setEditDecision(
        editDecision: EditDecision,
    ): Promise<void> {
        this.commit( 'setEditDecision', editDecision );
        return Promise.resolve();
    }

    public async trackErrorsFallingBackToGenericView(): Promise<void> {
        for ( const error of this.state.applicationErrors ) {
            this.store.$services.get( 'tracker' ).trackUnknownError( error.type );
        }
    }

    public async trackSavingErrorsFallingBackToGenericView(): Promise<void> {
        for ( const error of this.state.applicationErrors ) {
            this.store.$services.get( 'tracker' ).trackSavingUnknownError( error.type );
        }
    }

    public dismissWarningAnonymousEdit(): Promise<void> {
        this.commit( 'setShowWarningAnonymousEdit', false );
        return Promise.resolve();
    }

    public stopAssertingUserWhenSaving(): Promise<void> {
        this.commit( 'setAssertUserWhenSaving', false );
        return Promise.resolve();
    }

}