Skip to content

Commit

Permalink
Add link to admin page (#553)
Browse files Browse the repository at this point in the history
* Switch to using api v2 endpoints when fetching user

* Add optional admin_url field to user type definition

* Handle optional admin url in global state

* Show admin url in user menu

Only visible for users who have admin urls

* Add divider

* Add icons to admin_url and logout menu items

* Make admin_url menu item an anchor element

Better accessibility

* Make user menu more accessible

Also makes it easier to test the components

* Test rendering of Header componenet

* Update changelog

* Make admin url component an external react link

Since admin_url it is always an absolute URL

* Add aria label to admin url link component

For better accessibility
  • Loading branch information
podliashanyk authored Apr 16, 2024
1 parent 09e138b commit 803c7e9
Show file tree
Hide file tree
Showing 12 changed files with 125 additions and 12 deletions.
7 changes: 7 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ This file documents changes to Argus-frontend that are important for the users t
### Added

- Missing article in ticket generation modal.
- Link to Django administration site for users with admin permissions. Link is available in the user menu dropdown.


### Changed

- Styling in the user menu dropdown: added a horizontal divider and icons.




Expand Down
4 changes: 2 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,15 @@ class ApiClient {
// authUser: returns the information about an authenticated user
public authGetCurrentUser(): Promise<User> {
return this.resolveOrReject(
this.authGet<User, {}>("/api/v1/auth/user/"),
this.authGet<User, {}>("/api/v2/auth/user/"),
defaultResolver,
(error) => new Error(`Failed to get current user: ${getErrorCause(error)}`),
);
}

public getUser(userPK: number): Promise<User> {
return this.resolveOrReject(
this.authGet<User, {}>(`/api/v1/auth/users/${userPK}/`),
this.authGet<User, {}>(`/api/v2/auth/users/${userPK}/`),
defaultResolver,
(error) => new Error(`Failed to get user: ${getErrorCause(error)}`),
);
Expand Down
1 change: 1 addition & 0 deletions src/api/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface User {
first_name: string;
last_name: string;
email: string;
admin_url?: string;
}

export interface AuthTokenRequest {
Expand Down
74 changes: 74 additions & 0 deletions src/components/header/Header.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {render, screen, within} from "@testing-library/react";
import React from "react";
import Header from "./Header";
import {MemoryRouter} from "react-router-dom";
import userEvent from "@testing-library/user-event";


describe("Render Header", () => {
it("renders the Logo, both image and link", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);
expect(screen.getByRole('img', {name: /argus logo/i})).toBeInTheDocument();
expect(screen.getByRole('link', {name: /argus logo/i})).toBeInTheDocument();
});

it("renders the Incidents button", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);
expect(screen.getByRole('button', {name: 'Incidents'})).toBeInTheDocument();
});

it("renders the Timeslots button", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);
expect(screen.getByRole('button', {name: /timeslots/i})).toBeInTheDocument();
});

it("renders the Profiles button", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);
expect(screen.getByRole('button', {name: /profiles/i})).toBeInTheDocument();
});

it("renders the user menu button", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);
expect(screen.getByRole('button', {name: /user menu/i})).toBeInTheDocument();
});

it("renders only 2 default user menu items", () => {
render(
<MemoryRouter>
<Header/>
</MemoryRouter>
);

// Open user menu
userEvent.click(screen.getByRole('button', {name: /user menu/i}));
const userMenu = screen.getByRole('menu')
expect(userMenu).toBeInTheDocument()
expect(userMenu).toBeVisible()

// Expect 2 menu items to load - links to Destinations and Logout
const menuItems = within(userMenu).getAllByRole("menuitem")
expect(menuItems).toHaveLength(2)
expect(menuItems[0]).toHaveTextContent(/destinations/i)
expect(menuItems[1]).toHaveTextContent(/logout/i)
});
});
34 changes: 31 additions & 3 deletions src/components/header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import List from "@material-ui/core/List";
import IconButton from "@material-ui/core/IconButton";
import Skeleton from "@material-ui/lab/Skeleton";
import MenuIcon from "@material-ui/icons/Menu";
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import OpenInNewIcon from '@material-ui/icons/OpenInNew';

// Api
import api from "../../api";
Expand Down Expand Up @@ -99,6 +101,11 @@ const useStyles = makeStyles((theme: Theme) =>
cursor: "pointer",
},
},
menuItemWithIcon: {
display: "flex",
gap: "1rem",
flexWrap: 'nowrap',
}
}),
);

Expand Down Expand Up @@ -222,7 +229,26 @@ const Header: React.FC<HeaderPropsType> = () => {
<MenuItem onClick={handleMenuClose} component={Link} to="/destinations">
Destinations
</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>

<hr/>

{user.admin_url &&
<MenuItem className={classNames(style.menuItemWithIcon)}
aria-label="Link to site administartion"
component="a"
href={user.admin_url}
target='_blank'
rel='noopener noreferrer'
onClick={handleMenuClose}
>
<OpenInNewIcon />
Site Administration
</MenuItem>
}
<MenuItem className={classNames(style.menuItemWithIcon)} onClick={handleLogout}>
<ExitToAppIcon />
Logout
</MenuItem>
</Menu>
);

Expand All @@ -238,7 +264,8 @@ const Header: React.FC<HeaderPropsType> = () => {

<div className={style.grow} />
<div>
<div className={classNames(style.navItem, style.navItemSelected)} onClick={handleMenuOpen}>
<div role="button" aria-label="User menu" aria-controls={menuId} aria-haspopup="true"
className={classNames(style.navItem, style.navItemSelected)} onClick={handleMenuOpen}>
{user.isAuthenticated ? (
<div className={style.avatarContainer}>
<Avatar>{user.displayName[0]}</Avatar>
Expand Down Expand Up @@ -283,7 +310,8 @@ const Header: React.FC<HeaderPropsType> = () => {
</Link>
<div className={style.grow} />
<div>
<div className={classNames(style.navItem, style.navItemSelected)} onClick={handleMenuOpen}>
<div role="button" aria-label="User menu" aria-controls={menuId} aria-haspopup="true"
className={classNames(style.navItem, style.navItemSelected)} onClick={handleMenuOpen}>
{user.isAuthenticated ? (
<div className={style.avatarContainer}>
<Avatar>{user.displayName[0]}</Avatar>
Expand Down
4 changes: 2 additions & 2 deletions src/components/login/Login.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ describe("Functionality of LoginForm", () => {
apiMock
.onPost("/api/v1/token-auth/")
.reply(200, {token: "token"})
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
.reply(400);

render(<LoginForm/>);
Expand All @@ -156,7 +156,7 @@ describe("Functionality of LoginForm", () => {
apiMock
.onPost("/api/v1/token-auth/")
.reply(200, { token: "token" })
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
// eslint-disable-next-line @typescript-eslint/camelcase
.reply(200, { username: "test", first_name: "test", last_name: "test", email: "test" });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("AddDestinationDialog tests", () => {
apiMock
.onPost("/api/v1/token-auth/")
.reply(200, { token: "token" })
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
// eslint-disable-next-line @typescript-eslint/camelcase
.reply(200, { username: "test", first_name: "test", last_name: "test", email: "test" })
.onGet("/api/v2/notificationprofiles/media/email/json_schema/")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("Rendering profile list with no existing timeslots", () => {
apiMock
.onPost("/api/v1/token-auth/")
.reply(200, { token: "token" })
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
// eslint-disable-next-line @typescript-eslint/camelcase
.reply(200, { username: "test", first_name: "test", last_name: "test", email: "test" })
.onGet("/api/v2/notificationprofiles/")
Expand Down
2 changes: 1 addition & 1 deletion src/components/timeslotlist/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ beforeEach(() => {
.reply(200, [EXISTING_TIMESLOT] as Timeslot[])
.onPost("/api/v1/token-auth/")
.reply(200, { token: "token" })
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
// eslint-disable-next-line @typescript-eslint/camelcase
.reply(200, { username: "test", first_name: "test", last_name: "test", email: "test" })
;
Expand Down
3 changes: 3 additions & 0 deletions src/state/reducers/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type UserStateType = {
isAuthenticated: boolean;
isTokenAuthenticated: boolean;
token: string | undefined;
admin_url?: string;
};

export enum UserType {
Expand Down Expand Up @@ -42,6 +43,7 @@ export const userReducer = (state: UserStateType, action: UserActions) => {
isAuthenticated: true,
isTokenAuthenticated: false,
token: undefined,
admin_url: action.payload.admin_url,
};
case UserType.LoginToken:
const { user, token } = action.payload;
Expand All @@ -51,6 +53,7 @@ export const userReducer = (state: UserStateType, action: UserActions) => {
displayName: user.first_name,
isAuthenticated: true,
isTokenAuthenticated: true,
admin_url: user.admin_url,
};
case UserType.Logout: {
return {...initialUserState};
Expand Down
2 changes: 1 addition & 1 deletion src/views/incident/IncidentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ beforeEach(() => {
apiMock
.onPost("/api/v1/token-auth/")
.reply(200, { token: "token" })
.onGet("/api/v1/auth/user/")
.onGet("/api/v2/auth/user/")
// eslint-disable-next-line @typescript-eslint/camelcase
.reply(200, { username: "test", first_name: "test", last_name: "test", email: "test" })
.onGet("/api/v1/incidents/metadata/")
Expand Down
2 changes: 1 addition & 1 deletion src/views/login/LoginView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("Login Page: error handling", () => {
});

it("should not display wrong credentials helper text when request to fetch user fails", async () => {
apiMock.onGet("/api/v1/auth/user/").reply(404);
apiMock.onGet("/api/v2/auth/user/").reply(404);

render(<LoginView />);

Expand Down

0 comments on commit 803c7e9

Please sign in to comment.