Repository: openreachtech/dydx-trading-competition-frontend
Pull request #266: Show better UI feedbacks during competition enrollment/cancelation
Author: login: hecticme
Created: 2025-04-16T09:49:27Z | Merged: 2025-04-16T10:03:03Z

# Why

* Close https://chiho-internal.openreach.tech/tasks/2346

# How

* Show better UI feedbacks during competition enrollment/cancelation

# Screencasts

https://github.com/user-attachments/assets/776f2bab-3b4c-4802-af49-4205df24e52d



Diff:
diff --git a/app/vue/contexts/CompetitionDetailsPageContext.js b/app/vue/contexts/CompetitionDetailsPageContext.js
index 0bac6f78..764ca7d7 100644
--- a/app/vue/contexts/CompetitionDetailsPageContext.js
+++ b/app/vue/contexts/CompetitionDetailsPageContext.js
@@ -307,6 +307,25 @@ export default class CompetitionDetailsPageContext extends BaseFuroContext {
     return this
   }
 
+  /**
+   * Fetch competition participant.
+   *
+   * @returns {Promise<void>}
+   */
+  async fetchCompetitionParticipant () {
+    await this.graphqlClientHash
+      .competitionParticipant
+      .invokeRequestOnEvent({
+        variables: {
+          input: {
+            competitionId: this.extractCompetitionId(),
+            address: this.localWalletAddress,
+          },
+        },
+        hooks: this.competitionParticipantLauncherHooks,
+      })
+  }
+
   /**
    * Fetch leaderboard entries.
    *
@@ -394,6 +413,18 @@ export default class CompetitionDetailsPageContext extends BaseFuroContext {
       })
   }
 
+  /**
+   * Generate refetch hash. Have to be arrow function to conserve `this` scope.
+   *
+   * @returns {RefetchHash}
+   */
+  generateRefetchHash () {
+    return {
+      competitionParticipant: () => this.fetchCompetitionParticipant(),
+      leaderboardEntries: () => this.fetchLeaderboardEntries(),
+    }
+  }
+
   /**
    * Extract competition ID.
    *
@@ -494,17 +525,21 @@ export default class CompetitionDetailsPageContext extends BaseFuroContext {
           dialogElement: this.competitionCancelationDialog,
         })
 
-        await this.graphqlClientHash
-          .competitionParticipant
-          .invokeRequestOnEvent({
-            variables: {
-              input: {
-                competitionId: this.extractCompetitionId(),
-                address: this.localWalletAddress,
+        // TODO: Move mutation logic to mutation context.
+        await Promise.allSettled([
+          this.graphqlClientHash
+            .competitionParticipant
+            .invokeRequestOnEvent({
+              variables: {
+                input: {
+                  competitionId: this.extractCompetitionId(),
+                  address: this.localWalletAddress,
+                },
               },
-            },
-            hooks: this.competitionParticipantLauncherHooks,
-          })
+              hooks: this.competitionParticipantLauncherHooks,
+            }),
+          this.fetchLeaderboardEntries(),
+        ])
       },
     }
   }
@@ -1110,3 +1145,13 @@ export default class CompetitionDetailsPageContext extends BaseFuroContext {
  *   outcomePrize: string
  * }} NormalizedLeaderboardFinalOutcomeEntry
  */
+
+/**
+ * @typedef {Record<RefetchHashKeys, () => Promise<void>>} RefetchHash
+ */
+
+/**
+ * @typedef {'competitionParticipant'
+ *   | 'leaderboardEntries'
+ * } RefetchHashKeys
+ */
diff --git a/components/dialogs/CompetitionEnrollmentDialog.vue b/components/dialogs/CompetitionEnrollmentDialog.vue
index d5c47506..8dba9154 100644
--- a/components/dialogs/CompetitionEnrollmentDialog.vue
+++ b/components/dialogs/CompetitionEnrollmentDialog.vue
@@ -24,6 +24,11 @@ export default defineComponent({
   },
 
   props: {
+    isJoining: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     competition: {
       /** @type {import('vue').PropType<import('./CompetitionEnrollmentDialogContext').PropsType['competition']>} */
       type: Object,
@@ -148,7 +153,9 @@ export default defineComponent({
           {{ context.joinCompetitionErrorMessage }}
         </AppMessage>
 
-        <AppButton class="button">
+        <AppButton class="button"
+          :is-loading="context.isJoining"
+        >
           Enroll League
         </AppButton>
       </form>
diff --git a/components/dialogs/CompetitionEnrollmentDialogContext.js b/components/dialogs/CompetitionEnrollmentDialogContext.js
index 619a7f08..5f80d99d 100644
--- a/components/dialogs/CompetitionEnrollmentDialogContext.js
+++ b/components/dialogs/CompetitionEnrollmentDialogContext.js
@@ -59,6 +59,15 @@ export default class CompetitionEnrollmentDialogContext extends AppDialogContext
     }
   }
 
+  /**
+   * get: isJoining
+   *
+   * @returns {boolean}
+   */
+  get isJoining () {
+    return this.props.isJoining
+  }
+
   /**
    * get: validationMessage
    *
diff --git a/pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js b/pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js
index 00525950..c84a56ac 100644
--- a/pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js
+++ b/pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js
@@ -17,6 +17,8 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
     props,
     componentContext,
 
+    competitionEnrollmentDialogRef,
+    refetchHash,
     graphqlClientHash,
     formClerkHash,
     errorMessageHashReactive,
@@ -27,6 +29,8 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
       componentContext,
     })
 
+    this.competitionEnrollmentDialogRef = competitionEnrollmentDialogRef
+    this.refetchHash = refetchHash
     this.graphqlClientHash = graphqlClientHash
     this.formClerkHash = formClerkHash
     this.errorMessageHashReactive = errorMessageHashReactive
@@ -45,6 +49,8 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
   static create ({
     props,
     componentContext,
+    competitionEnrollmentDialogRef,
+    refetchHash,
     graphqlClientHash,
     formClerkHash,
     errorMessageHashReactive,
@@ -54,6 +60,8 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
       new this({
         props,
         componentContext,
+        competitionEnrollmentDialogRef,
+        refetchHash,
         graphqlClientHash,
         formClerkHash,
         errorMessageHashReactive,
@@ -62,6 +70,15 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
     )
   }
 
+  /**
+   * get: competitionEnrollmentDialogRef
+   *
+   * @returns {import('~/components/units/AppDialog.vue').default | null}
+   */
+  get competitionEnrollmentDialog () {
+    return this.competitionEnrollmentDialogRef.value
+  }
+
   /**
    * get: isJoining
    *
@@ -129,7 +146,18 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
 
         if (capsule.hasError()) {
           this.errorMessageHashReactive.joinCompetition = capsule.getResolvedErrorMessage()
+
+          return
+        }
+
+        if (this.competitionEnrollmentDialog) {
+          this.competitionEnrollmentDialog.dismissDialog()
         }
+
+        await Promise.allSettled([
+          this.refetchHash.competitionParticipant(),
+          this.refetchHash.leaderboardEntries(),
+        ])
       },
     }
   }
@@ -137,6 +165,8 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
 
 /**
  * @typedef {import('@openreachtech/furo-nuxt/lib/contexts/BaseFuroContext').BaseFuroContextParams<{}> & {
+ *   competitionEnrollmentDialogRef: import('vue').Ref<import('~/components/units/AppDialog.vue').default | null>
+ *   refetchHash: import('~/app/vue/contexts/CompetitionDetailsPageContext').RefetchHash
  *   graphqlClientHash: Record<GraphqlClientHashKeys, GraphqlClient>
  *   formClerkHash: Record<FormClerkHashKeys, FormClerk>
  *   errorMessageHashReactive: import('vue').Reactive<ErrorMessageHash>
diff --git a/pages/(competitions)/competitions/[competitionId]/index.vue b/pages/(competitions)/competitions/[competitionId]/index.vue
index cc64d6b0..6a13be37 100644
--- a/pages/(competitions)/competitions/[competitionId]/index.vue
+++ b/pages/(competitions)/competitions/[competitionId]/index.vue
@@ -116,6 +116,8 @@ export default defineComponent({
     const mutationArgs = {
       props,
       componentContext,
+      competitionEnrollmentDialogRef,
+      refetchHash: context.generateRefetchHash(),
       graphqlClientHash: {
         joinCompetition: joinCompetitionGraphqlClient,
       },
@@ -175,6 +177,7 @@ export default defineComponent({
     <CompetitionEnrollmentDialog ref="competitionEnrollmentDialogRef"
       :competition="context.competition"
       :initial-username="context.addressName"
+      :is-joining="mutationContext.isJoining"
       :validation-message="mutationContext.joinCompetitionValidationMessage"
       :error-message-hash="mutationContext.errorMessageHashReactive"
       @join-competition="mutationContext.joinCompetition({


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR improves the UI feedback during competition enrollment and cancelation by propagating additional state (i.e. isJoining flag) and handling refetching of competition data on mutation events. The changes include:
- Passing the new competitionEnrollmentDialogRef and refetchHash to the mutation context.
- Introducing the isJoining flag to control the enrolment button's loading state.
- Adding helper methods in CompetitionDetailsPageContext to support refetching data.

### Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| pages/(competitions)/competitions/[competitionId]/index.vue | Added new properties (competitionEnrollmentDialogRef and refetchHash) to the mutation arguments and passed isJoining to the dialog component. |
| pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js | Updated the mutation context to include dialog reference and refetch hash, and added a getter for competitionEnrollmentDialog. |
| components/dialogs/CompetitionEnrollmentDialogContext.js | Introduced a new getter isJoining to proxy the joining state from props. |
| components/dialogs/CompetitionEnrollmentDialog.vue | Added a new prop isJoining with a default value and updated the enrollment button to show a loading state. |
| app/vue/contexts/CompetitionDetailsPageContext.js | Added new methods fetchCompetitionParticipant and generateRefetchHash to support data refetching post-mutation. |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:50:03Z
user: login: StewEucen
body: 🍵
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:51:13Z
user: login: StewEucen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:52:27Z
user: login: EG-easy
body: 🍵
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:54:06Z

Review comments:
diff_hunk: @@ -130,13 +147,24 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
         if (capsule.hasError()) {
           this.errorMessageHashReactive.joinCompetition = capsule.getResolvedErrorMessage()
         }
+
+        if (this.competitionEnrollmentDialog) {
+          this.competitionEnrollmentDialog.dismissDialog()
path: pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js
user: login: Copilot
body: Currently, the dialog is dismissed regardless of whether an error occurred during enrollment. Consider dismissing the enrollment dialog only after a successful join to allow users to see error feedback in case of failure.
```suggestion
        } else if (this.competitionEnrollmentDialog) {
```
created_at: 2025-04-16T09:50:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -130,13 +147,24 @@ export default class CompetitionDetailsPageMutationContext extends BaseFuroConte
         if (capsule.hasError()) {
           this.errorMessageHashReactive.joinCompetition = capsule.getResolvedErrorMessage()
         }
+
+        if (this.competitionEnrollmentDialog) {
+          this.competitionEnrollmentDialog.dismissDialog()
path: pages/(competitions)/competitions/[competitionId]/CompetitionDetailsPageMutationContext.js
user: login: StewEucen
body: We do not require ugly `else if` logic
created_at: 2025-04-16T09:52:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-16T09:50:06Z
body: ## Deploying dydx-trading-competition-frontend with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>98106b0</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://d7abaa10.dydx-trading-competition-frontend.pages.dev'>https://d7abaa10.dydx-trading-competition-frontend.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feat-feedback-enroll-competi.dydx-trading-competition-frontend.pages.dev'>https://feat-feedback-enroll-competi.dydx-trading-competition-frontend.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/82be5ca4754b67b076b29647ce0cc739/pages/view/dydx-trading-competition-frontend/d7abaa10-c022-4f90-a0f8-f476e4d2435e)

author_association: NONE
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-16T09:50:11Z
body: ## Deploying dydx-trading-competition-frontend-production with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>98106b0</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://2dd5f4de.dydx-trading-competition-frontend-production.pages.dev'>https://2dd5f4de.dydx-trading-competition-frontend-production.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feat-feedback-enroll-competi.dydx-trading-competition-frontend-production.pages.dev'>https://feat-feedback-enroll-competi.dydx-trading-competition-frontend-production.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/82be5ca4754b67b076b29647ce0cc739/pages/view/dydx-trading-competition-frontend-production/2dd5f4de-f031-4857-aa30-981dbf3391aa)

author_association: NONE