From b42fc2551bfbaa93c75d520c4904ce88173eae4c Mon Sep 17 00:00:00 2001
From: Lukas 'Eipi' Eipert <leipert@gitlab.com>
Date: Thu, 30 Nov 2023 09:31:58 +0000
Subject: [PATCH] Refactor how user avatar is updated

Updating the user avatar was done by simply replacing the URL of the
avatar elements. This is potentially tricky if the underlying Avatar
implementation changes (and also was done via the test-id).

Let's trigger a custom event instead once a user uploads a new avatar.
---
 .../edit/components/profile_edit_app.vue      | 21 ++++++-------------
 app/assets/javascripts/profile/profile.js     |  9 +++-----
 .../super_sidebar/components/user_menu.vue    | 15 ++++++++++++-
 .../user_uploads_avatar_to_profile_spec.rb    |  6 ++++--
 .../edit/components/profile_edit_app_spec.js  |  6 ++++--
 .../components/user_menu_spec.js              | 14 +++++++++++++
 6 files changed, 45 insertions(+), 26 deletions(-)

diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
index 815b8742500d3..eedb5d7764e60 100644
--- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
+++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue
@@ -3,7 +3,6 @@ import { nextTick } from 'vue';
 import { GlForm, GlButton } from '@gitlab/ui';
 import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/alert';
 import axios from '~/lib/utils/axios_utils';
-import { readFileAsDataURL } from '~/lib/utils/file_utility';
 import SetStatusForm from '~/set_status_modal/set_status_form.vue';
 import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
 import { isUserBusy, computedClearStatusAfterValue } from '~/set_status_modal/utils';
@@ -106,20 +105,12 @@ export default {
         this.updateProfileSettings = false;
       }
     },
-    async syncHeaderAvatars() {
-      const dataURL = await readFileAsDataURL(this.avatarBlob);
-
-      const elements = gon?.use_new_navigation
-        ? ['[data-testid="user-dropdown"] .gl-avatar']
-        : ['.header-user-avatar', '.js-sidebar-user-avatar'];
-
-      elements.forEach((selector) => {
-        const node = document.querySelector(selector);
-        if (!node) return;
-
-        node.setAttribute('src', dataURL);
-        node.setAttribute('srcset', dataURL);
-      });
+    syncHeaderAvatars() {
+      document.dispatchEvent(
+        new CustomEvent('userAvatar:update', {
+          detail: { url: URL.createObjectURL(this.avatarBlob) },
+        }),
+      );
     },
     onBlobChange(blob) {
       this.avatarBlob = blob;
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 4d3824f910ce7..16f0110a1afe1 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -89,12 +89,9 @@ export default class Profile {
   }
 
   updateHeaderAvatar() {
-    if (gon?.use_new_navigation) {
-      $('[data-testid="user-dropdown"] .gl-avatar').attr('src', this.avatarGlCrop.dataURL);
-    } else {
-      $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL);
-      $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL);
-    }
+    const url = URL.createObjectURL(this.avatarGlCrop.getBlob());
+
+    document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } }));
   }
 
   setRepoRadio() {
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index db769df873fd7..5dab74374dfaa 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -59,9 +59,13 @@ export default {
   data() {
     return {
       setStatusModalReady: false,
+      updatedAvatarUrl: null,
     };
   },
   computed: {
+    avatarUrl() {
+      return this.updatedAvatarUrl || this.data.avatar_url;
+    },
     toggleText() {
       return sprintf(__('%{user} user’s menu'), { user: this.data.name });
     },
@@ -190,7 +194,16 @@ export default {
       };
     },
   },
+  mounted() {
+    document.addEventListener('userAvatar:update', this.updateAvatar);
+  },
+  unmounted() {
+    document.removeEventListener('userAvatar:update', this.updateAvatar);
+  },
   methods: {
+    updateAvatar(event) {
+      this.updatedAvatarUrl = event.detail?.url;
+    },
     onShow() {
       this.initBuyCIMinsCallout();
     },
@@ -240,7 +253,7 @@ export default {
           <gl-avatar
             :size="24"
             :entity-name="data.name"
-            :src="data.avatar_url"
+            :src="avatarUrl"
             aria-hidden="true"
             data-testid="user-avatar-content"
           />
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 83eb7cb989e36..5d121d9eeba9c 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -18,8 +18,10 @@
 
       wait_for_all_requests
 
-      data_uri = find('.avatar-image .gl-avatar')['src']
-      within_testid('user-dropdown') { expect(find('.gl-avatar')['src']).to eq data_uri }
+      within_testid('user-dropdown') do
+        # We are setting a blob URL
+        expect(find('.gl-avatar')['src']).to start_with 'blob:'
+      end
 
       visit profile_path
 
diff --git a/spec/frontend/profile/edit/components/profile_edit_app_spec.js b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
index 31a368aefa916..39bf597352bd6 100644
--- a/spec/frontend/profile/edit/components/profile_edit_app_spec.js
+++ b/spec/frontend/profile/edit/components/profile_edit_app_spec.js
@@ -3,7 +3,6 @@ import MockAdapter from 'axios-mock-adapter';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import waitForPromises from 'helpers/wait_for_promises';
 
-import { readFileAsDataURL } from '~/lib/utils/file_utility';
 import axios from '~/lib/utils/axios_utils';
 import ProfileEditApp from '~/profile/edit/components/profile_edit_app.vue';
 import UserAvatar from '~/profile/edit/components/user_avatar.vue';
@@ -103,6 +102,8 @@ describe('Profile Edit App', () => {
     });
 
     it('syncs header avatars', async () => {
+      jest.spyOn(document, 'dispatchEvent');
+      jest.spyOn(URL, 'createObjectURL');
       mockAxios.onPut(stubbedProfilePath).reply(200, {
         message: successMessage,
       });
@@ -112,7 +113,8 @@ describe('Profile Edit App', () => {
 
       await waitForPromises();
 
-      expect(readFileAsDataURL).toHaveBeenCalledWith(mockAvatarFile);
+      expect(URL.createObjectURL).toHaveBeenCalledWith(mockAvatarFile);
+      expect(document.dispatchEvent).toHaveBeenCalledWith(new CustomEvent('userAvatar:update'));
     });
 
     it('contains changes from the status form', async () => {
diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js
index ba675c8b3f514..4af3247693be9 100644
--- a/spec/frontend/super_sidebar/components/user_menu_spec.js
+++ b/spec/frontend/super_sidebar/components/user_menu_spec.js
@@ -78,6 +78,20 @@ describe('UserMenu component', () => {
       });
     });
 
+    it('updates avatar url on custom avatar update event', async () => {
+      const url = `${userMenuMockData.avatar_url}-new-avatar`;
+
+      document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } }));
+      await nextTick();
+
+      const avatar = toggle.findComponent(GlAvatar);
+      expect(avatar.exists()).toBe(true);
+      expect(avatar.props()).toMatchObject({
+        entityName: userMenuMockData.name,
+        src: url,
+      });
+    });
+
     it('renders screen reader text', () => {
       expect(toggle.find('.gl-sr-only').text()).toBe(`${userMenuMockData.name} user’s menu`);
     });
-- 
GitLab