Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add validation and error summary to comment form #485

Merged
merged 2 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading