-
Notifications
You must be signed in to change notification settings - Fork 5
refactor(users): account view route-driven via SurfaceTabBar — homogeneous chrome with Org/Admin #4185
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
refactor(users): account view route-driven via SurfaceTabBar — homogeneous chrome with Org/Admin #4185
Changes from all commits
4d2dea7
24df320
6d9a67e
fa3c25b
9b1cd4c
8a3fe89
fcd5253
c87362e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -46,6 +46,31 @@ export default [ | |
| position: 'bottom', | ||
| requiresAuth: true, | ||
| }, | ||
| children: [ | ||
| { | ||
| // bare /users → /users/profile (default child) | ||
| path: '', | ||
| redirect: { name: 'Account Profile' }, | ||
| }, | ||
| { | ||
| path: 'profile', | ||
| name: 'Account Profile', | ||
| component: () => import('../views/user.profile.view.vue'), | ||
| meta: { | ||
| display: false, | ||
| requiresAuth: true, | ||
| }, | ||
| }, | ||
| { | ||
| path: 'organizations', | ||
| name: 'Account Organizations', | ||
| component: () => import('../views/user.organizations.view.vue'), | ||
|
Comment on lines
+58
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Add JSDoc for the new lazy-load route component factories. Both new function literals ( As per coding guidelines, “Every new or modified function must have a JSDoc header with one-line description, 🤖 Prompt for AI Agents |
||
| meta: { | ||
| display: false, | ||
| requiresAuth: true, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| { | ||
| path: '/users/:id', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { describe, test, expect, vi, beforeEach } from 'vitest'; | ||
| import { shallowMount } from '@vue/test-utils'; | ||
| import { createPinia, setActivePinia } from 'pinia'; | ||
| import UserOrganizationsView from '../views/user.organizations.view.vue'; | ||
|
|
||
| // Mock config service | ||
| vi.mock('../../../lib/services/config', () => ({ | ||
| default: { | ||
| api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, | ||
| cookie: { prefix: 'devkit' }, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn() })); | ||
| vi.mock('../../../lib/helpers/roleColor', () => ({ default: () => 'primary' })); | ||
| vi.mock('../../../lib/helpers/orgColor', () => ({ default: () => 'blue' })); | ||
|
|
||
| const sharedStubs = { | ||
| orgAvatarComponent: { template: '<div />' }, | ||
| 'v-container': { template: '<div><slot /></div>' }, | ||
| 'v-list': { template: '<div><slot /></div>' }, | ||
| 'v-list-item': { template: '<div><slot /></div>' }, | ||
| 'v-list-item-title': { template: '<div><slot /></div>' }, | ||
| 'v-list-item-subtitle': { template: '<div><slot /></div>' }, | ||
| 'v-divider': { template: '<div />' }, | ||
| 'v-chip': { template: '<div><slot /></div>' }, | ||
| 'v-btn': { template: '<button v-bind="$attrs" :to="$attrs.to"><slot /></button>', inheritAttrs: false }, | ||
| 'v-icon': { template: '<div />' }, | ||
| 'v-dialog': { template: '<div><slot /></div>' }, | ||
| 'v-card': { template: '<div><slot /></div>' }, | ||
| 'v-card-title': { template: '<div><slot /></div>' }, | ||
| 'v-card-text': { template: '<div><slot /></div>' }, | ||
| 'v-card-actions': { template: '<div><slot /></div>' }, | ||
| 'v-spacer': { template: '<div />' }, | ||
| }; | ||
|
|
||
| const sharedMocks = ($router = { push: vi.fn() }) => ({ | ||
| $router, | ||
| $route: { path: '/users/organizations' }, | ||
| config: { | ||
| api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, | ||
| vuetify: { theme: { rounded: 'rounded-lg', flat: true } }, | ||
| }, | ||
| }); | ||
|
Comment on lines
+37
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win Document
As per coding guidelines, every function must have JSDoc header with 🤖 Prompt for AI Agents |
||
|
|
||
| describe('user.organizations.view', () => { | ||
| beforeEach(() => { | ||
| setActivePinia(createPinia()); | ||
| }); | ||
|
|
||
| test('renders the new-org button with data-test="users-orgs-new"', async () => { | ||
| const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); | ||
| const store = useOrganizationsStore(); | ||
| store.fetchOrganizations = vi.fn().mockResolvedValue([]); | ||
|
|
||
| const wrapper = shallowMount(UserOrganizationsView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| expect(wrapper.find('[data-test="users-orgs-new"]').exists()).toBe(true); | ||
| }); | ||
|
|
||
| test('leaveDialog defaults to false', async () => { | ||
| const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); | ||
| const store = useOrganizationsStore(); | ||
| store.fetchOrganizations = vi.fn().mockResolvedValue([]); | ||
|
|
||
| const wrapper = shallowMount(UserOrganizationsView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| expect(wrapper.vm.leaveDialog).toBe(false); | ||
| }); | ||
|
|
||
| test('confirmLeave sets orgToLeave and opens leaveDialog', async () => { | ||
| const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); | ||
| const store = useOrganizationsStore(); | ||
| store.fetchOrganizations = vi.fn().mockResolvedValue([]); | ||
|
|
||
| const wrapper = shallowMount(UserOrganizationsView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| const org = { id: 'org-1', name: 'Test Org', role: 'member' }; | ||
| wrapper.vm.confirmLeave(org); | ||
|
|
||
| expect(wrapper.vm.orgToLeave).toEqual(org); | ||
| expect(wrapper.vm.leaveDialog).toBe(true); | ||
| }); | ||
|
|
||
| test('leaveOrg redirects to /organization-required when no orgs remain', async () => { | ||
| const { useOrganizationsStore } = await import('../../organizations/stores/organizations.store'); | ||
| const { useAuthStore } = await import('../../auth/stores/auth.store'); | ||
| const store = useOrganizationsStore(); | ||
| const authStore = useAuthStore(); | ||
|
|
||
| store.fetchOrganizations = vi.fn().mockResolvedValue([]); | ||
| const routerPush = vi.fn(); | ||
|
|
||
| const wrapper = shallowMount(UserOrganizationsView, { | ||
| global: { | ||
| mocks: sharedMocks({ push: routerPush }), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| const orgId = 'org-1'; | ||
| wrapper.vm.orgToLeave = { id: orgId, name: 'Only Org' }; | ||
|
|
||
| store.leaveOrganization = vi.fn().mockImplementation(() => { | ||
| store.organizations = []; | ||
| return Promise.resolve(); | ||
| }); | ||
| authStore.refreshAbilities = vi.fn().mockResolvedValue(); | ||
|
|
||
| await wrapper.vm.leaveOrg(); | ||
|
|
||
| expect(store.leaveOrganization).toHaveBeenCalledWith(orgId); | ||
| expect(routerPush).toHaveBeenCalledWith('/organization-required'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import { describe, test, expect, vi, beforeEach } from 'vitest'; | ||
| import { shallowMount } from '@vue/test-utils'; | ||
| import { createPinia, setActivePinia } from 'pinia'; | ||
| import UserProfileView from '../views/user.profile.view.vue'; | ||
|
|
||
| // Mock axios | ||
| vi.mock('../../../lib/services/axios', () => ({ | ||
| default: { | ||
| get: vi.fn(), | ||
| post: vi.fn(), | ||
| put: vi.fn(), | ||
| delete: vi.fn(), | ||
| }, | ||
| })); | ||
|
|
||
| // Mock config service | ||
| vi.mock('../../../lib/services/config', () => ({ | ||
| default: { | ||
| api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, | ||
| cookie: { prefix: 'devkit' }, | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('../../../lib/helpers/ability', () => ({ updateAbilities: vi.fn() })); | ||
|
|
||
| const sharedStubs = { | ||
| userProfileComponent: { template: '<div data-test="user-profile-component" />', name: 'UserProfileComponent' }, | ||
| 'v-container': { template: '<div><slot /></div>' }, | ||
| 'v-card': { template: '<div><slot /></div>' }, | ||
| 'v-card-title': { template: '<div><slot /></div>' }, | ||
| 'v-card-text': { template: '<div><slot /></div>' }, | ||
| 'v-card-actions': { template: '<div><slot /></div>' }, | ||
| 'v-btn': { template: '<button v-bind="$attrs"><slot /></button>' }, | ||
| 'v-dialog': { template: '<div><slot /></div>' }, | ||
| 'v-text-field': { template: '<div />' }, | ||
| 'v-spacer': { template: '<div />' }, | ||
| }; | ||
|
|
||
| const sharedMocks = ($router = { push: vi.fn() }) => ({ | ||
| $router, | ||
| $route: { path: '/users/profile' }, | ||
| config: { | ||
| api: { protocol: 'http', host: 'localhost', port: '3000', base: 'api' }, | ||
| vuetify: { theme: { rounded: 'rounded-lg', flat: true } }, | ||
| }, | ||
| }); | ||
|
|
||
| describe('user.profile.view', () => { | ||
| beforeEach(() => { | ||
| setActivePinia(createPinia()); | ||
| }); | ||
|
|
||
| test('renders the userProfileComponent', () => { | ||
| const wrapper = shallowMount(UserProfileView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
| expect(wrapper.findComponent({ name: 'UserProfileComponent' }).exists()).toBe(true); | ||
| }); | ||
|
|
||
| test('renders the danger zone Delete Account card', () => { | ||
| const wrapper = shallowMount(UserProfileView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
| expect(wrapper.html()).toContain('Delete Account'); | ||
| }); | ||
|
|
||
| test('confirmDeleteAccount defaults to false', () => { | ||
| const wrapper = shallowMount(UserProfileView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
| expect(wrapper.vm.confirmDeleteAccount).toBe(false); | ||
| }); | ||
|
|
||
| test('deleteAccount method calls axios.delete and redirects to /signin on success', async () => { | ||
| const axios = (await import('../../../lib/services/axios')).default; | ||
| const routerPush = vi.fn(); | ||
|
|
||
| const wrapper = shallowMount(UserProfileView, { | ||
| global: { | ||
| mocks: sharedMocks({ push: routerPush }), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| const { useAuthStore } = await import('../../auth/stores/auth.store'); | ||
| const authStore = useAuthStore(); | ||
| authStore.signout = vi.fn().mockResolvedValue(); | ||
| axios.delete.mockResolvedValue({}); | ||
|
|
||
| await wrapper.vm.deleteAccount(); | ||
|
|
||
| expect(axios.delete).toHaveBeenCalledWith(expect.stringContaining('/users')); | ||
| expect(authStore.signout).toHaveBeenCalled(); | ||
| expect(routerPush).toHaveBeenCalledWith('/signin'); | ||
| }); | ||
|
|
||
| test('deleteAccount closes dialog on error (swallows exception)', async () => { | ||
| const axios = (await import('../../../lib/services/axios')).default; | ||
|
|
||
| const wrapper = shallowMount(UserProfileView, { | ||
| global: { | ||
| mocks: sharedMocks(), | ||
| stubs: sharedStubs, | ||
| }, | ||
| }); | ||
|
|
||
| wrapper.vm.confirmDeleteAccount = true; | ||
| wrapper.vm.deleteConfirmInput = 'DELETE'; | ||
| axios.delete.mockRejectedValue(new Error('Server error')); | ||
|
|
||
| await wrapper.vm.deleteAccount(); | ||
|
|
||
| expect(wrapper.vm.confirmDeleteAccount).toBe(false); | ||
| expect(wrapper.vm.deleteConfirmInput).toBe(''); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Avoid
networkidlehere; prefer deterministic readiness checks.networkidlecan introduce E2E flakiness when background requests are present. Since you already assert the target list item visibility, removenetworkidleand rely on explicit element/API completion conditions instead.🤖 Prompt for AI Agents