Skip to content

Commit

Permalink
Merge pull request #485 from wmde/comment-validation
Browse files Browse the repository at this point in the history
Add validation and error summary to comment form
  • Loading branch information
gbirke authored Sep 30, 2024
2 parents 6c746e3 + 927f917 commit 2f909d0
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 17 deletions.
35 changes: 35 additions & 0 deletions src/api/CommentResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import axios, { AxiosResponse } from 'axios';

export interface CommentRequest {
donationId: number;
updateToken: string;
comment: string;
withName: boolean;
isPublic: boolean;
}

interface CommentResponse {
status: string;
message: string;
}

export interface CommentResource {
post: ( data: CommentRequest ) => Promise<string>;
}

export class ApiCommentResource implements CommentResource {
postEndpoint: string;

constructor( postEndpoint: string ) {
this.postEndpoint = postEndpoint;
}

post( data: CommentRequest ): Promise<string> {
return axios.post( this.postEndpoint, data ).then( ( validationResult: AxiosResponse<CommentResponse> ) => {
if ( validationResult.data.status !== 'OK' ) {
return Promise.reject( validationResult.data.message );
}
return validationResult.data.message;
} );
}
}
83 changes: 66 additions & 17 deletions src/components/pages/donation_confirmation/DonationCommentPopUp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
<input type="hidden" name="donationId" :value="donation.id"/>
<input type="hidden" name="updateToken" :value="donation.updateToken">
<div v-if="commentHasBeenSubmitted">
<p v-html="$t( serverResponse )"></p>
<p class="donation-comment-server-response" v-html="$t( serverResponse )"></p>
<FormButton
button-type="button"
class="donation-comment-return-button"
:is-outlined="true"
@click="$emit( 'close' )"
>
Expand All @@ -15,14 +16,15 @@
<div v-else>
<p>{{ $t( 'donation_comment_popup_explanation' ) }}</p>

<ScrollTarget target-id="comment-scroll-target"/>
<TextField
input-type="textarea"
v-model="comment"
name="comment"
input-id="comment"
placeholder=""
:label="$t( 'donation_comment_popup_label' )"
:error-message="$t( 'donation_comment_popup_error' )"
:error-message="$t( commentError )"
:show-error="commentErrored"
:autofocus="true"
/>
Expand All @@ -44,6 +46,18 @@
{{ $t( 'donation_comment_popup_is_public' ) }}
</CheckboxField>

<ErrorSummary
:is-visible="commentErrored"
:items="[
{
validity: commentErrored ? Validity.INVALID : Validity.VALID,
message: $t( commentError ),
focusElement: 'comment',
scrollElement: 'comment-scroll-target'
},
]"
/>

<FormSummary :show-border="false">
<template #summary-buttons>
<FormButton
Expand All @@ -66,52 +80,87 @@
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import axios, { AxiosResponse } from 'axios';
import { computed, inject, onMounted, ref, watch } from 'vue';
import { trackDynamicForm, trackFormSubmission } from '@src/util/tracking';
import { addressTypeFromName, AddressTypeModel } from '@src/view_models/AddressTypeModel';
import { Donation } from '@src/view_models/Donation';
import FormButton from '@src/components/shared/form_elements/FormButton.vue';
import FormSummary from '@src/components/shared/FormSummary.vue';
import TextField from '@src/components/shared/form_fields/TextField.vue';
import CheckboxField from '@src/components/shared/form_fields/CheckboxField.vue';
import { CommentResource } from '@src/api/CommentResource';
import ErrorSummary from '@src/components/shared/validation_summary/ErrorSummary.vue';
import { Validity } from '@src/view_models/Validity';
import ScrollTarget from '@src/components/shared/ScrollTarget.vue';
enum CommentErrorTypes {
Empty,
Server
}
interface Props {
donation: Donation;
addressType: string;
postCommentUrl: string;
}
const props = defineProps<Props>();
const emit = defineEmits( [ 'disable-comment-link' ] );
const emit = defineEmits( [ 'disable-comment-link', 'close' ] );
const commentResource = inject<CommentResource>( 'commentResource' );
const commentForm = ref<HTMLFormElement>( null );
const comment = ref<string>( '' );
const commentIsPublic = ref<boolean>( false );
const commentHasPublicAuthorName = ref<boolean>( false );
const commentErrored = ref<boolean>( false );
const commentErrorType = ref<CommentErrorTypes>( CommentErrorTypes.Empty );
const commentHasBeenSubmitted = ref<boolean>( false );
const serverResponse = ref<string>( '' );
const serverError = ref<string>( '' );
const showPublishAuthor = computed<boolean>( () => addressTypeFromName( props.addressType ) !== AddressTypeModel.ANON );
const postComment = (): void => {
const postComment = (): Promise<void> => {
trackFormSubmission( commentForm.value );
const jsonForm = new FormData( commentForm.value );
axios.post( props.postCommentUrl, jsonForm ).then( ( validationResult: AxiosResponse<any> ) => {
if ( validationResult.data.status === 'OK' ) {
commentErrored.value = false;
commentHasBeenSubmitted.value = true;
serverResponse.value = validationResult.data.message;
emit( 'disable-comment-link' );
} else {
commentErrored.value = true;
}
if ( comment.value === '' ) {
commentErrorType.value = CommentErrorTypes.Empty;
commentErrored.value = true;
return;
}
commentResource.post( {
donationId: props.donation.id,
updateToken: props.donation.updateToken,
comment: comment.value,
withName: commentHasPublicAuthorName.value,
isPublic: commentIsPublic.value,
} ).then( ( message: string ) => {
commentErrored.value = false;
commentHasBeenSubmitted.value = true;
serverResponse.value = message;
emit( 'disable-comment-link' );
} ).catch( ( message: string ) => {
commentErrorType.value = CommentErrorTypes.Server;
commentErrored.value = true;
serverError.value = message;
} );
};
onMounted( trackDynamicForm );
const commentError = computed<string>( () => {
if ( commentErrorType.value === CommentErrorTypes.Empty ) {
return 'donation_comment_popup_empty_error';
}
return serverError.value;
} );
watch( comment, ( newComment: string ) => {
if ( commentErrored.value && newComment !== '' ) {
commentErrored.value = false;
}
} );
</script>

<style lang="scss">
Expand Down
2 changes: 2 additions & 0 deletions src/pages/donation_confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import DonationConfirmation from '@src/components/pages/DonationConfirmation.vue
import { createFeatureFetcher } from '@src/util/FeatureFetcher';
import { bucketIdToCssClass } from '@src/util/bucket_id_to_css_class';
import { ApiCityAutocompleteResource } from '@src/api/CityAutocompleteResource';
import { ApiCommentResource } from '@src/api/CommentResource';

interface DonationConfirmationModel {
urls: { [ key: string ]: string },
Expand Down Expand Up @@ -93,5 +94,6 @@ store.dispatch(
} );
app.use( store );
app.provide( 'cityAutocompleteResource', new ApiCityAutocompleteResource() );
app.provide( 'commentResource', new ApiCommentResource( pageData.applicationVars.urls.postComment ) );
app.mount( '#app' );
} );
16 changes: 16 additions & 0 deletions tests/unit/TestDoubles/FakeCommentResource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CommentResource } from '@src/api/CommentResource';

export const successMessage = 'Success';
export const failureMessage = 'Fail';

export class FakeSucceedingCommentResource implements CommentResource {
post(): Promise<string> {
return Promise.resolve( successMessage );
}
}

export class FakeFailingCommentResource implements CommentResource {
post(): Promise<string> {
return Promise.reject( failureMessage );
}
}
84 changes: 84 additions & 0 deletions tests/unit/components/DonationCommentPopUp.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mount } from '@vue/test-utils';
import DonationCommentPopUp from '@src/components/pages/donation_confirmation/DonationCommentPopUp.vue';
import { AddressTypeModel, addressTypeName } from '@src/view_models/AddressTypeModel';
import { failureMessage, FakeFailingCommentResource, FakeSucceedingCommentResource, successMessage } from '@test/unit/TestDoubles/FakeCommentResource';

describe( 'DonationCommentPopUp.vue', () => {
function getDefaultConfirmationData( isAnonymous: boolean ): any {
Expand Down Expand Up @@ -34,6 +35,11 @@ describe( 'DonationCommentPopUp.vue', () => {
it( 'displays anyonmous comment toggle for private / company donations', () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( false ),
global: {
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );

expect( wrapper.find( '#withName' ).exists() ).toBeTruthy();
Expand All @@ -42,8 +48,86 @@ describe( 'DonationCommentPopUp.vue', () => {
it( 'hides anyonmous comment toggle for anonymous donations', () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( true ),
global: {
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );

expect( wrapper.find( '#withName' ).exists() ).toBeFalsy();
} );

it( 'shows error when comment is empty', async () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( true ),
global: {
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );

await wrapper.trigger( 'submit' );

expect( wrapper.find( '#comment-error' ).exists() ).toBeTruthy();
expect( wrapper.find( '.error-summary' ).exists() ).toBeTruthy();
expect( wrapper.find( '#comment-error' ).text() ).toStrictEqual( 'donation_comment_popup_empty_error' );
} );

it( 'resets error when comment text is entered', async () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( true ),
global: {
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );

await wrapper.trigger( 'submit' );

expect( wrapper.find( '#comment-error' ).exists() ).toBeTruthy();
expect( wrapper.find( '.error-summary' ).exists() ).toBeTruthy();

await wrapper.find( '#comment' ).setValue( 'My super great comment' );

expect( wrapper.find( '#comment-error' ).exists() ).toBeFalsy();
expect( wrapper.find( '.error-summary' ).exists() ).toBeFalsy();
} );

it( 'shows error when API response is rejected', async () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( true ),
global: {
provide: {
commentResource: new FakeFailingCommentResource(),
},
},
} );

await wrapper.find( '#comment' ).setValue( 'My super great comment' );
await wrapper.trigger( 'submit' );

expect( wrapper.find( '#comment-error' ).exists() ).toBeTruthy();
expect( wrapper.find( '.error-summary' ).exists() ).toBeTruthy();
expect( wrapper.find( '#comment-error' ).text() ).toStrictEqual( failureMessage );
} );

it( 'shows message returned from API', async () => {
const wrapper = mount( DonationCommentPopUp, {
props: getDefaultConfirmationData( true ),
global: {
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );

await wrapper.find( '#comment' ).setValue( 'My super great comment' );
await wrapper.trigger( 'submit' );

expect( wrapper.find( '.donation-comment-server-response' ).text() ).toStrictEqual( successMessage );
expect( wrapper.find( '.donation-comment-return-button' ).exists() ).toBeTruthy();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@test/data/confirmationData';
import { addressValidationPatterns } from '@test/data/validation';
import { DonorResource } from '@src/api/DonorResource';
import { FakeSucceedingCommentResource } from '@test/unit/TestDoubles/FakeCommentResource';

describe( 'DonationConfirmation.vue', () => {
const getWrapper = ( bankData: ConfirmationData, translateMock: ( key: string ) => string = ( key: string ) => key ): VueWrapper<any> => {
Expand All @@ -39,6 +40,9 @@ describe( 'DonationConfirmation.vue', () => {
$t: translateMock,
$n: () => {},
},
provide: {
commentResource: new FakeSucceedingCommentResource(),
},
},
} );
};
Expand Down

0 comments on commit 2f909d0

Please sign in to comment.