Skip to content

Commit d6128b2

Browse files
sagzyclaude
andcommitted
🐛 Fixed staff profile URL not updating after changing a user's slug
no ref - the old Ember staff screen rewrote the browser URL (via replaceState) when a user's slug was saved, so refresh/back/bookmarks kept working; this behaviour was lost when staff management moved to the React settings app, leaving the address bar pointing at a dead slug - changing your own slug was even worse: the modal compares the current user's slug against the URL param, so after saving, the profile modal silently unmounted - added a `replace` option to the (legacy) routing provider's updateRoute so the modal can swap the history entry without adding a new one, mirroring the old Ember behaviour - the user detail modal now updates the route to the server-returned slug after a save, keeps showing the last loaded user while the refetch by new slug is in flight, and shows a "User not found" toast (navigating back to the staff list) instead of rendering nothing when a stale/unknown slug URL is opened Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9e2f320 commit d6128b2

3 files changed

Lines changed: 148 additions & 7 deletions

File tree

apps/admin-x-framework/src/providers/routing-provider.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ExternalLink = {
2121
export type InternalLink = {
2222
isExternal?: false;
2323
route: string;
24+
replace?: boolean;
2425
}
2526

2627
export type RoutingModalProps = {
@@ -125,13 +126,17 @@ export const RoutingProvider: React.FC<RoutingProviderProps> = ({basePath, modal
125126
}
126127

127128
const newPath = options.route.replace(/^\//, '');
129+
const newHash = newPath ? `/${basePath}/${newPath}` : `/${basePath}`;
128130

129131
if (newPath === route) {
130132
// No change
131-
} else if (newPath) {
132-
window.location.hash = `/${basePath}/${newPath}`;
133+
} else if (options.replace) {
134+
// Replace the current history entry so back/refresh use the new
135+
// path, then fire hashchange manually since replaceState doesn't
136+
window.history.replaceState(window.history.state, '', `#${newHash}`);
137+
window.dispatchEvent(new HashChangeEvent('hashchange'));
133138
} else {
134-
window.location.hash = `/${basePath}`;
139+
window.location.hash = newHash;
135140
}
136141

137142
eventTarget.dispatchEvent(new CustomEvent('routeChange', {detail: {newPath, oldPath: route}}));

apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import EmailNotificationsTab from './users/email-notifications-tab';
22
import NiceModal, {useModal} from '@ebay/nice-modal-react';
33
import ProfileTab from './users/profile-tab';
4-
import React, {useCallback, useState} from 'react';
4+
import React, {useCallback, useEffect, useRef, useState} from 'react';
55
import SocialLinksTab from './users/social-links-tab';
66
import clsx from 'clsx';
77
import usePinturaEditor from '../../../hooks/use-pintura-editor';
@@ -95,7 +95,18 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
9595
}, {});
9696
},
9797
onSave: async (values) => {
98-
await updateUser?.(values);
98+
const response = await updateUser?.(values);
99+
100+
// The server may have sanitized the submitted slug
101+
const savedSlug = response?.users?.[0]?.slug;
102+
103+
if (savedSlug && savedSlug !== user.slug) {
104+
// Keep the URL in sync with the new slug, replacing the
105+
// history entry so refresh and back button still work
106+
const tab = getTabFromPath(route);
107+
const urlSegment = tab === 'profile' ? '' : `/${tab}`;
108+
updateRoute({route: `staff/${savedSlug}${urlSegment}`, replace: true});
109+
}
99110
},
100111
onSaveError: handleError
101112
});
@@ -482,21 +493,51 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
482493

483494
const UserDetailModal: React.FC<RoutingModalProps> = ({params}) => {
484495
const {currentUser} = useGlobalData();
496+
const {updateRoute} = useRouting();
485497

486498
// Skip API call if it's the current user (we already have their data)
487499
const isCurrentUser = currentUser.slug === params?.slug;
488500

489501
// Fetch user by slug if it's not the current user
490-
const {data: fetchedUserData} = useGetUserBySlug(
502+
const {data: fetchedUserData, isError} = useGetUserBySlug(
491503
params?.slug || '',
492504
{enabled: !isCurrentUser && !!params?.slug}
493505
);
494506

495507
// Use current user data or fetched user data
496508
const user = isCurrentUser ? currentUser : fetchedUserData?.users?.[0];
497509

510+
// Keep showing the last loaded user while a refetch is in flight, e.g.
511+
// when a slug change updates the URL and triggers a fetch by the new slug
512+
const lastUserRef = useRef<User | undefined>(undefined);
498513
if (user) {
499-
return <UserDetailModalContent user={user} />;
514+
lastUserRef.current = user;
515+
}
516+
const displayUser = user || lastUserRef.current;
517+
518+
const notFoundSlug = (!displayUser && !!params?.slug && (isError || fetchedUserData !== undefined)) ? params.slug : null;
519+
const notFoundHandledRef = useRef<string | null>(null);
520+
521+
useEffect(() => {
522+
if (!notFoundSlug || notFoundHandledRef.current === notFoundSlug) {
523+
return;
524+
}
525+
notFoundHandledRef.current = notFoundSlug;
526+
527+
showToast({
528+
type: 'error',
529+
message: 'User not found'
530+
});
531+
532+
if (canAccessSettings(currentUser)) {
533+
updateRoute('staff');
534+
} else {
535+
updateRoute({isExternal: true, route: ''});
536+
}
537+
}, [notFoundSlug, currentUser, updateRoute]);
538+
539+
if (displayUser) {
540+
return <UserDetailModalContent user={displayUser} />;
500541
} else {
501542
return null;
502543
}

apps/admin-x-settings/test/acceptance/general/users/profile.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,3 +932,98 @@ test.describe('User profile', async () => {
932932
});
933933
});
934934
});
935+
936+
test.describe('Staff profile URLs', async () => {
937+
test('Updates the URL and keeps the modal open when changing another user\'s slug', async ({page}) => {
938+
const userToEdit = responseFixtures.users.users.find(user => user.email === 'administrator@test.com')!;
939+
940+
await mockApi({page, requests: {
941+
...globalDataRequests,
942+
getUserBySlug: {method: 'GET', path: `/users/slug/${userToEdit.slug}/?include=roles`, response: {
943+
users: [userToEdit]
944+
}},
945+
getUserByNewSlug: {method: 'GET', path: '/users/slug/new-admin/?include=roles', response: {
946+
users: [{...userToEdit, slug: 'new-admin'}]
947+
}},
948+
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
949+
editUser: {method: 'PUT', path: `/users/${userToEdit.id}/?include=roles`, response: {
950+
users: [{...userToEdit, slug: 'new-admin'}]
951+
}}
952+
}});
953+
954+
await page.goto('/');
955+
956+
const section = page.getByTestId('users');
957+
const activeTab = section.locator('[role=tabpanel]:not(.hidden)');
958+
959+
await section.getByRole('tab', {name: 'Administrators'}).click();
960+
961+
const listItem = activeTab.getByTestId('user-list-item').last();
962+
await listItem.hover();
963+
await listItem.getByRole('button', {name: 'Edit'}).click();
964+
965+
const modal = page.getByTestId('user-detail-modal');
966+
967+
await expect(page).toHaveURL(new RegExp(`#/settings/staff/${userToEdit.slug}$`));
968+
969+
await modal.getByLabel('Slug').fill('new-admin');
970+
await modal.getByRole('button', {name: 'Save'}).click();
971+
972+
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
973+
974+
// The URL follows the new slug and the modal stays open
975+
await expect(page).toHaveURL(/#\/settings\/staff\/new-admin$/);
976+
await expect(modal).toBeVisible();
977+
978+
// Refreshing the updated URL loads the same profile
979+
await page.reload();
980+
await expect(page.getByTestId('user-detail-modal')).toBeVisible();
981+
await expect(page).toHaveURL(/#\/settings\/staff\/new-admin$/);
982+
});
983+
984+
test('Updates the URL and keeps the modal open when changing your own slug', async ({page}) => {
985+
const ownerUser = responseFixtures.users.users.find(user => user.email === 'owner@test.com')!;
986+
987+
await mockApi({page, requests: {
988+
...globalDataRequests,
989+
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
990+
editUser: {method: 'PUT', path: `/users/${ownerUser.id}/?include=roles`, response: {
991+
users: [{...ownerUser, slug: 'renamed-owner'}]
992+
}}
993+
}});
994+
995+
await page.goto('/');
996+
997+
const section = page.getByTestId('users');
998+
999+
const listItem = section.getByTestId('owner-user').last();
1000+
await listItem.hover();
1001+
await listItem.getByRole('button', {name: 'View profile'}).click();
1002+
1003+
const modal = page.getByTestId('user-detail-modal');
1004+
1005+
await modal.getByLabel('Slug').fill('renamed-owner');
1006+
await modal.getByRole('button', {name: 'Save'}).click();
1007+
1008+
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
1009+
1010+
await expect(page).toHaveURL(/#\/settings\/staff\/renamed-owner$/);
1011+
await expect(modal).toBeVisible();
1012+
});
1013+
1014+
test('Shows an error and returns to the staff list when the user is not found', async ({page}) => {
1015+
await mockApi({page, requests: {
1016+
...globalDataRequests,
1017+
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: responseFixtures.users},
1018+
getUserBySlug: {method: 'GET', path: '/users/slug/unknown-user/?include=roles', responseStatus: 404, response: {
1019+
errors: [{type: 'NotFoundError', message: 'Resource not found error, cannot read user.'}]
1020+
}}
1021+
}});
1022+
1023+
await page.goto('/#/settings/staff/unknown-user');
1024+
1025+
await expect(page.getByTestId('toast-error').filter({hasText: 'User not found'})).toBeVisible();
1026+
await expect(page).toHaveURL(/#\/settings\/staff$/);
1027+
await expect(page.getByTestId('user-detail-modal')).not.toBeVisible();
1028+
});
1029+
});

0 commit comments

Comments
 (0)