Skip to content

Commit

Permalink
Merge pull request #696 from wmde/improve-form-action
Browse files Browse the repository at this point in the history
Improve form action domain model
  • Loading branch information
moiikana authored Feb 11, 2025
2 parents 19c7fec + f951655 commit 2c18b85
Show file tree
Hide file tree
Showing 29 changed files with 332 additions and 100 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ import { Tracker } from '@src/tracking/Tracker';
import { useBannerHider } from '@src/components/composables/useBannerHider';
import BannerTitle from '../content/BannerTitle.vue';
import { useFormActionWithReceipt } from '@src/components/composables/useFormActionWithReceipt';
import { FormActions } from '@src/domain/FormActions';
import { FormActionCollection } from '@src/domain/FormActions';
const minimumAmount = 10;
Expand Down Expand Up @@ -148,7 +148,7 @@ const stepControllers = [
createSubmittableUpgradeToYearly( formModel, FormStepNames.MainDonationFormStep, FormStepNames.MainDonationFormStep )
];
const { formAction } = useFormActionWithReceipt( inject<FormActions>( 'formActions' ), minimumAmount );
const { formAction } = useFormActionWithReceipt( inject<FormActionCollection>( 'formActions' ), minimumAmount );
watch( contentState, async () => {
emit( 'bannerContentChanged' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ import SoftClose from '@src/components/SoftClose/SoftClose.vue';
import WMDEFundsForwardingEN from '@src/components/UseOfFunds/Infographics/WMDEFundsForwardingEN.vue';
import { useBannerHider } from '@src/components/composables/useBannerHider';
import { useFormActionWithReceipt } from '@src/components/composables/useFormActionWithReceipt';
import { FormActions } from '@src/domain/FormActions';
import { FormActionCollection } from '@src/domain/FormActions';
const minimumAmount = 10;
Expand Down Expand Up @@ -149,7 +149,7 @@ const stepControllers = [
createSubmittableUpgradeToYearly( formModel, FormStepNames.MainDonationFormStep, FormStepNames.MainDonationFormStep )
];
const { formAction } = useFormActionWithReceipt( inject<FormActions>( 'formActions' ), minimumAmount );
const { formAction } = useFormActionWithReceipt( inject<FormActionCollection>( 'formActions' ), minimumAmount );
watch( contentState, async () => {
emit( 'bannerContentChanged' );
Expand Down
63 changes: 55 additions & 8 deletions docs/Features/FormActions.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,62 @@
# Form Actions

A `form action` is the action an HTML form will perform when the form gets submitted.
In our case the action can be different **URLs** that the user will then get redirected to (spenden.wikimedia.de).
## Background information
A `form action` is URL in an HTML that the browser will send the form data to, when the user submits the form.
In our case, the actions are two different **end points** (URL with protocol, host name and path ) of the fundraising application:

The URL can have different paths and parameters.
It depends on different A/B tests we want to perform on the fundraising application.
- One for showing the donation form, with pre-filled payment data (path `/donation/new`)
- One for immediately creating an anonymous donation (path `/donation/add`)

The banners currently use different Vue `composables` that help encapsulating the URL creation process:
The Fundraising Application also needs different **URL parameters** from the banner:

- The Matomo campaign parameters for associating a donation with a banner
- Impression counts, to check the effectiveness of showing banners repeatedly.
- (optional) Additional parameters that trigger A/B test or feature toggle behavior

### Important classes
- The `FormAction` class encapsulates URL generation with the different parameters, with the tracking parameters being mandatory. Do not add parameters to the URL via string concatenation, always use its `setParameter` functions!
- The `FormActionCollection` holds the two actions.

These two classes are part of the entry point initialization (`banner_ctrl.ts` and `banner_var.ts`), are not reactive. We inject the initialized `FormActionCollection` with the `formActions` key, so all components have access to it.
To choose one action (and potentially changing parameters) means our form action must be _reactive_. We achieve this though using Vue composables, by default `useFormAction`.

## Default form action
- **file name(s):**
- `useFormActions.ts`
- `FormActions.ts`
- `createFormActions.ts`
- `MultiStepDonation.vue` (can override default form action from `useFormAction`)
- **description:**
- If the user can explicitly choose anonymous donations in the form (setting `formModel.addressType.value` to `AddressTypes.ANONYMOUS.value`) _and_ the payment type is not direct debit, then it will return the URL for the direct, anonymous donation
- In all other cases, it will return the URL for the donation
- **used in (banner name):**
- All banners not listed elsewhere in this document.

## useFormAction.ts
- basic URL that redirects to an anonymous donation process or a donation page where they can choose address options

## useFormActionWithReceipt.ts
- used when the donation form asks the user about whether they need a receipt
- **file name(s):**
- `useFormActionWithReceipt.ts`
- `FormActions.ts`
- `createFormActions.ts`
- `BannerCtrl.vue` / `BannerVar.vue` (sets result of custom form action composable to `form-action-override` of `MultiStepDonation` component )
- **description:**
- See [Show Donation Receipt checkbox only below a certain amount threshold with different submit button labels](DonationForms.md)
- **used in (banner name):**
- `C25_WMDE_Desktop_DE_00 VAR`
- `C25_WMDE_Desktop_EN_00 VAR`

## Creating a new banner test with same banner, but different A/B test behavior in the Fundraising application

Edit the `banner_var.ts` entry point and add additional parameters to the call `createFormActions`:

```typescript
// example CTRL
app.provide( 'formActions', createFormActions( page.getTracking(), impressionCount ) );
// example VAR
app.provide( 'formActions', createFormActions( page.getTracking(), impressionCount, { ap: '1' } ) );

```

## Creating a new behavior, based on form state
Create a new `useFormActionZZZ` composable (see default `useFormAction.ts` for reference), use it in `BannerVar.vue`. Set `formAction.value` of custom form action composable to `form-action-override` of `MultiStepDonation` component

5 changes: 3 additions & 2 deletions src/components/DonationForm/MultiStepDonation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ import { PageScroller } from '@src/utils/PageScroller/PageScroller';
import { Tracker } from '@src/tracking/Tracker';
import { TrackingEvent } from '@src/tracking/TrackingEvent';
import { useFormAction } from '@src/components/composables/useFormAction';
import { FormActions } from '@src/domain/FormActions';
import { FormActionCollection } from '@src/domain/FormActions';
import { Timer } from '@src/utils/Timer';
interface Props {
stepControllers: StepController[];
pageScroller?: PageScroller;
// This allows banners to override the default `useFormAction` composable
formActionOverride?: string;
// This is to allow the banner to trigger side effects when the form is submitted
submitCallback?: () => void;
Expand All @@ -56,7 +57,7 @@ usedSlotNames.forEach( ( slotName: string, index: number ): void => {
const tracker = inject<Tracker>( 'tracker' );
const timer = inject<Timer>( 'timer' );
const currentStepIndex = ref<number>( 0 );
const defaultFormAction = useFormAction( inject<FormActions>( 'formActions' ) );
const defaultFormAction = useFormAction( inject<FormActionCollection>( 'formActions' ) );
const formAction = computed( (): string => {
return props.formActionOverride ? props.formActionOverride : defaultFormAction.formAction.value;
} );
Expand Down
12 changes: 6 additions & 6 deletions src/components/composables/useFormAction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormActions } from '@src/domain/FormActions';
import { FormActionCollection } from '@src/domain/FormActions';
import { computed, Ref } from 'vue';
import { AddressTypes } from '@src/utils/FormItemsBuilder/fields/AddressTypes';
import { useFormModel } from '@src/components/composables/useFormModel';
Expand All @@ -11,20 +11,20 @@ import { PaymentMethods } from '@src/utils/FormItemsBuilder/fields/PaymentMethod
* When a user wants to donate via direct debit, they will have to specify their address.
*
* The form action should be independent of the form and only rely on the FormModel.
* @param { FormActions } formActions
* @param { FormActionCollection } formActions
*/
export function useFormAction( formActions: FormActions ): { formAction: Ref<string> } {
export function useFormAction( formActions: FormActionCollection ): { formAction: Ref<string> } {
const formModel = useFormModel();
const formAction = computed( (): string => {
if ( formModel.addressType.value !== AddressTypes.ANONYMOUS.value ) {
return formActions.donateWithAddressAction;
return formActions.donateWithAddressAction.toString();
}

if ( formModel.paymentMethod.value === PaymentMethods.DIRECT_DEBIT.value ) {
return formActions.donateWithAddressAction;
return formActions.donateWithAddressAction.toString();
}

return formActions.donateAnonymouslyAction;
return formActions.donateAnonymouslyAction.toString();
} );

return {
Expand Down
14 changes: 8 additions & 6 deletions src/components/composables/useFormActionWithReceipt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormActions } from '@src/domain/FormActions';
import { FormActionCollection } from '@src/domain/FormActions';
import { computed, Ref } from 'vue';
import { useFormModel } from '@src/components/composables/useFormModel';
import { PaymentMethods } from '@src/utils/FormItemsBuilder/fields/PaymentMethods';
Expand All @@ -17,22 +17,24 @@ import { PaymentMethods } from '@src/utils/FormItemsBuilder/fields/PaymentMethod
* Else they will be redirected to an anonymous donation.
*
* The form action should be independent of the form and only rely on the FormModel.
* @param { FormActions } formActions
* @param { FormActionCollection } formActions
* @param { number } minimumAmount Threshold at where a donation counts as "big donation" (where a receipt might be wanted)
*/
export function useFormActionWithReceipt( formActions: FormActions, minimumAmount: number ): { formAction: Ref<string> } {
export function useFormActionWithReceipt( formActions: FormActionCollection, minimumAmount: number ): { formAction: Ref<string> } {
const formModel = useFormModel();
const formAction = computed( (): string => {

let URL: string = formActions.donateAnonymouslyAction;
let action = formActions.donateAnonymouslyAction;

if ( formModel.numericAmount.value >= minimumAmount ||
formModel.receipt.value ||
formModel.paymentMethod.value === PaymentMethods.DIRECT_DEBIT.value ) {
URL = formActions.donateWithAddressAction + '&ap=1';
action = formActions.donateWithAddressAction;
// Use address page without anonymous option
action.setParameter( 'ap', '1' );
}

return URL;
return action.toString();
} );

return {
Expand Down
24 changes: 11 additions & 13 deletions src/createFormActions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-disable camelcase */
import { FormActions } from '@src/domain/FormActions';
import { FormAction, FormActionCollection } from '@src/domain/FormActions';
import { TrackingParameters } from '@src/domain/TrackingParameters';
import { ImpressionCount } from '@src/utils/ImpressionCount';

Expand All @@ -15,18 +14,17 @@ const DONATE_ANONYMOUSLY_URL = 'https://spenden.wikimedia.de/donation/add';
* @param {ImpressionCount} impressionCount
* @param {Record<string, string>} extraUrlParameters
*/
export function createFormActions( tracking: TrackingParameters, impressionCount: ImpressionCount, extraUrlParameters: Record<string, string> = {} ): FormActions {
const urlParameters = new URLSearchParams( {
piwik_kwd: tracking.keyword,
piwik_campaign: tracking.campaign,
banner_submission: '1',
export function createFormActions(
tracking: TrackingParameters,
impressionCount: ImpressionCount,
extraUrlParameters: Record<string, string> = {}
): FormActionCollection {
const urlParameters: Record<string, string> = {
impCount: String( impressionCount.overallCountIncremented ),
bImpCount: String( impressionCount.bannerCountIncremented ),
...extraUrlParameters
} );

return {
donateWithAddressAction: `${DONATE_WITH_ADDRESS_URL}?${urlParameters}`,
donateAnonymouslyAction: `${DONATE_ANONYMOUSLY_URL}?${urlParameters}`
...extraUrlParameters,
};
const withAddressAction = new FormAction( DONATE_WITH_ADDRESS_URL, tracking, urlParameters );
const anonymouslyAction = new FormAction( DONATE_ANONYMOUSLY_URL, tracking, urlParameters );
return new FormActionCollection( withAddressAction, anonymouslyAction );
}
55 changes: 52 additions & 3 deletions src/domain/FormActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,53 @@
export interface FormActions {
donateWithAddressAction: string;
donateAnonymouslyAction: string;
import { TrackingParameters } from '@src/domain/TrackingParameters';

/**
* This class encapsulates URL generation to an end point to the fundraising application and all parameters needed for
* the fundraising application.
*
* The constructor takes the static, non-reactive values available in the entry point. The form components will use
* the injected FormAction classes to dynamically generate a form action from the available, injected form actions
* ( see `useFormAction` and other composables starting with `useFormAction`).
*
* If you need to add parameters inside a reactive property (`useFormAction`),
* use the `setParameter` method of this class instead of using string concatenation!
*/
export class FormAction {
private readonly url: string;
private readonly params: Record<string, string>;
public constructor( url: string, tracking: TrackingParameters, extraUrlParameters: Record<string, string> = {} ) {
this.url = url;
this.params = {
/* eslint-disable camelcase */
piwik_kwd: tracking.keyword,
piwik_campaign: tracking.campaign,
banner_submission: '1',
/* eslint-enable camelcase */
...extraUrlParameters
};
}

public setParameter( name: string, value: string ): FormAction {
this.params[ name ] = value;
return this;
}

public get actionUrl(): string {
const urlParams = new URLSearchParams( this.params );
return `${this.url}?${urlParams}`;
}

public toString(): string {
return this.actionUrl;
}
}

/**
* This class represents the available end points of the fundraising application.
*/
export class FormActionCollection {
public constructor(
public readonly donateWithAddressAction: FormAction,
public readonly donateAnonymouslyAction: FormAction
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { softCloseSubmitTrackingFeaturesDesktop } from '@test/features/SoftClose
import { Tracker } from '@src/tracking/Tracker';
import { TimerStub } from '@test/fixtures/TimerStub';
import { Timer } from '@src/utils/Timer';
import { fakeFormActions } from '@test/fixtures/FakeFormActions';

const formModel = useFormModel();
const translator = ( key: string ): string => key;
Expand Down Expand Up @@ -59,10 +60,7 @@ describe( 'BannerCtrl.vue', () => {
translator: { translate: translator },
dynamicCampaignText: dynamicContent ?? newDynamicContent(),
currentCampaignTimePercentage: 42,
formActions: {
donateWithAddressAction: 'https://example.com/with-address',
donateAnonymouslyAction: 'https://example.com/without-address'
},
formActions: fakeFormActions,
currencyFormatter: new CurrencyEn(),
formItems,
tracker,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { softCloseSubmitTrackingFeaturesDesktop } from '@test/features/SoftClose
import { Tracker } from '@src/tracking/Tracker';
import { TimerStub } from '@test/fixtures/TimerStub';
import { Timer } from '@src/utils/Timer';
import { fakeFormActions } from '@test/fixtures/FakeFormActions';

const formModel = useFormModel();
const translator = ( key: string ): string => key;
Expand Down Expand Up @@ -59,10 +60,7 @@ describe( 'BannerVar.vue', () => {
translator: { translate: translator },
dynamicCampaignText: dynamicContent ?? newDynamicContent(),
currentCampaignTimePercentage: 42,
formActions: {
donateWithAddressAction: 'https://example.com/with-address',
donateAnonymouslyAction: 'https://example.com/without-address'
},
formActions: fakeFormActions,
currencyFormatter: new CurrencyEn(),
formItems,
tracker,
Expand Down Expand Up @@ -142,7 +140,9 @@ describe( 'BannerVar.vue', () => {
await wrapper.find( '.amount-10 input' ).trigger( 'change' );
await wrapper.find( '.payment-ppl input' ).trigger( 'change' );

expect( wrapper.find<HTMLFormElement>( '.wmde-banner-submit-form' ).element.action ).toContain( 'with-address&ap=1' );
const formActionAttribute = wrapper.find<HTMLFormElement>( '.wmde-banner-submit-form' ).element.action;
expect( formActionAttribute ).toContain( 'with-address' );
expect( formActionAttribute ).toContain( 'ap=1' );
} );

it( 'Set the correct action when amount is below 10 and receipt is not checked', async (): Promise<void> => {
Expand All @@ -163,7 +163,9 @@ describe( 'BannerVar.vue', () => {
await wrapper.find( '.payment-ppl input' ).trigger( 'change' );
await wrapper.find( '#wmde-banner-form-donation-receipt' ).trigger( 'click' );

expect( wrapper.find<HTMLFormElement>( '.wmde-banner-submit-form' ).element.action ).toContain( 'with-address&ap=1' );
const formActionAttribute = wrapper.find<HTMLFormElement>( '.wmde-banner-submit-form' ).element.action;
expect( formActionAttribute ).toContain( 'with-address' );
expect( formActionAttribute ).toContain( 'ap=1' );
} );

it( 'Puts the receipt option into the submit form', async (): Promise<void> => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { alreadyDonatedLinkFeatures } from '@test/features/AlreadyDonatedLink';
import { softCloseFeatures } from '@test/features/SoftCloseDesktop';
import { Timer } from '@src/utils/Timer';
import { TimerStub } from '@test/fixtures/TimerStub';
import { fakeFormActions } from '@test/fixtures/FakeFormActions';

const formModel = useFormModel();
const translator = ( key: string ): string => key;
Expand All @@ -43,7 +44,7 @@ describe( 'BannerCtrl.vue', () => {
provide: {
translator: { translate: translator },
dynamicCampaignText: dynamicContent ?? newDynamicContent(),
formActions: { donateWithAddressAction: 'https://example.com', donateWithoutAddressAction: 'https://example.com' },
formActions: fakeFormActions,
currencyFormatter: new CurrencyEn(),
formItems,
tracker: new TrackerStub(),
Expand Down
Loading

0 comments on commit 2c18b85

Please sign in to comment.