Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend-v2/src/app/backendApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4488,10 +4488,14 @@ export type User = {
email?: string;
};
export type Profile = {
/** the user's department */
department?: string;
user: number;
};
export type ProfileRead = {
id: number;
/** the user's department */
department?: string;
user: number;
};
export type UserRead = {
Expand Down
88 changes: 72 additions & 16 deletions frontend-v2/src/features/projects/UserAccess.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC } from "react";
import { FC, useState } from "react";
import {
Dialog,
DialogContent,
Expand All @@ -13,6 +13,9 @@ import {
Box,
Button,
Typography,
Autocomplete,
TextField,
createFilterOptions,
} from "@mui/material";
import {
ProjectAccess,
Expand All @@ -24,7 +27,6 @@ import { FormData } from "./Project";
import Delete from "@mui/icons-material/Delete";
import { useSelector } from "react-redux";
import { selectCurrentUser } from "../login/loginSlice";
import DropdownButton from "../../components/DropdownButton";

interface Props {
open: boolean;
Expand All @@ -37,6 +39,25 @@ interface Props {
onClose: () => void;
}

interface UserOption {
id: number;
username: string;
firstName: string;
lastName: string;
}

const MIN_SEARCH_LENGTH = 3;

const fullName = (firstName?: string, lastName?: string) =>
`${firstName || ""} ${lastName || ""}`.trim();

const optionLabel = (option: UserOption) => {
const name = fullName(option.firstName, option.lastName);
return name ? `${option.username} (${name})` : option.username;
};

const defaultFilter = createFilterOptions<UserOption>();

const UserAccess: FC<Props> = ({
open,
userAccess,
Expand All @@ -46,6 +67,7 @@ const UserAccess: FC<Props> = ({
onClose,
}) => {
const { data: users } = useUserListQuery();
const [inputValue, setInputValue] = useState("");

// create map from user id to user object
const userMap = new Map();
Expand All @@ -65,13 +87,16 @@ const UserAccess: FC<Props> = ({
const myUserId = currentUser?.id || 0;
const sharedUsers = userAccess.map(({ user }) => user);

// create list of user options for select
const userOptions =
// create list of user options for the autocomplete
const userOptions: UserOption[] =
users
?.filter((user) => user.id !== myUserId && !sharedUsers.includes(user.id))
.map((user) => {
return { value: user.id, label: user.username };
}) || [];
.map((user) => ({
id: user.id,
username: user.username,
firstName: user.first_name || "",
lastName: user.last_name || "",
})) || [];

return (
<Dialog maxWidth="lg" open={open} onClose={onClose}>
Expand All @@ -85,16 +110,24 @@ const UserAccess: FC<Props> = ({
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Name</TableCell>
<TableCell>Department</TableCell>
<TableCell>Remove Access</TableCell>
</TableRow>
</TableHead>
<TableBody>
{userAccess.map((user, i) => {
const isMe = user.user === myUserId;
const userName = userMap.get(user.user)?.username;
const userData = userMap.get(user.user);
const userName = userData?.username;
const name =
fullName(userData?.first_name, userData?.last_name) || "—";
const department = userData?.profile?.department || "—";
return (
<TableRow key={user.user}>
<TableCell>{userName}</TableCell>
<TableCell>{name}</TableCell>
<TableCell>{department}</TableCell>
<TableCell>
{!isMe && (
<IconButton onClick={deleteAccess(user, i)}>
Expand All @@ -107,15 +140,38 @@ const UserAccess: FC<Props> = ({
})}
</TableBody>
</Table>
<DropdownButton
useIcon={false}
data_cy="add-y-axis"
options={userOptions}
onOptionSelected={addUser}
<Autocomplete
sx={{ marginTop: "1rem" }}
>
Add user
</DropdownButton>
options={userOptions}
value={null}
inputValue={inputValue}
onInputChange={(_event, newInputValue) =>
setInputValue(newInputValue)
}
blurOnSelect
clearOnBlur
getOptionLabel={optionLabel}
isOptionEqualToValue={(option, value) => option.id === value.id}
filterOptions={(options, state) =>
state.inputValue.length < MIN_SEARCH_LENGTH
? []
: defaultFilter(options, state)
}
noOptionsText={
inputValue.length < MIN_SEARCH_LENGTH
? `Type at least ${MIN_SEARCH_LENGTH} characters to search`
: "No users found"
}
onChange={(_event, newValue) => {
if (newValue) {
addUser(newValue.id);
setInputValue("");
}
}}
renderInput={(params) => (
<TextField {...params} label="Add user" data-cy="add-user" />
)}
/>

<Box
sx={{
Expand Down
181 changes: 181 additions & 0 deletions frontend-v2/src/stories/UserAccess.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within, screen, waitFor } from "storybook/test";
import { useForm, useFieldArray } from "react-hook-form";
import { http, HttpResponse } from "msw";
import { useDispatch } from "react-redux";

import UserAccess from "../features/projects/UserAccess";
import { FormData } from "../features/projects/Project";
import { setCredentials } from "../features/login/loginSlice";
import { project, compound } from "./project.mock";
import { ProjectAccess, UserRead } from "../app/backendApi";

// Users returned by GET /api/user. id 1 is the logged-in user and is already in
// project.user_access (so it appears in the "Shared with" table); ids 2 and 3 are
// available to add via the autocomplete.
const users = [
{
id: 1,
username: "storybook_test_user",
first_name: "Storybook",
last_name: "Test",
email: "test@pkpdapp.com",
profile: { id: 1, department: "Clinical Pharmacology", user: 1 },
project_set: [project.id],
},
{
id: 2,
username: "asmith",
first_name: "Alice",
last_name: "Smith",
email: "alice.smith@pkpdapp.com",
profile: { id: 2, department: "Pharmacometrics", user: 2 },
project_set: [],
},
{
id: 3,
username: "bjones",
first_name: "Bob",
last_name: "Jones",
email: "bob.jones@pkpdapp.com",
profile: { id: 3, department: "Drug Metabolism", user: 3 },
project_set: [],
},
] as unknown as UserRead[];

const currentUser = users[0];

// A harness that supplies the react-hook-form control/append/remove that
// UserAccess needs, mirroring how Project.tsx wires the dialog. See
// Parameters.population.stories.tsx for the same pattern.
const Harness = () => {
const { control } = useForm<FormData>({
defaultValues: { project, compound },
});
const {
fields: userAccess,
append,
remove,
} = useFieldArray<FormData>({
control,
name: "project.user_access",
});
return (
<UserAccess
open
project={project}
userAccess={userAccess as ProjectAccess[]}
append={append}
remove={remove}
control={control}
onCancel={() => {}}
onClose={() => {}}
/>
);
};

const meta: Meta<typeof UserAccess> = {
title: "Projects/UserAccess",
component: UserAccess,
parameters: {
msw: {
handlers: [
http.get("/api/user", async () => {
return HttpResponse.json(users, { status: 200 });
}),
],
},
},
decorators: [
(Story) => {
const dispatch = useDispatch();
dispatch(setCredentials({ user: currentUser, csrf: "" }));
return <Story />;
},
],
render: () => <Harness />,
};

export default meta;
type Story = StoryObj<typeof UserAccess>;

// The row in the "Shared with" table for a given username.
const rowForUser = (username: string) =>
screen.getByText(username).closest("tr") as HTMLTableRowElement;

export const Default: Story = {
play: async () => {
// The dialog renders in a portal, so query via `screen`, not the canvas.
// The title is a Typography h4 nested in MUI's DialogTitle (h2), so scope to
// level 4 to avoid matching both headings.
const title = await screen.findByRole("heading", {
name: "Share Project",
level: 4,
});
expect(title).toBeInTheDocument();

// The shared user's row shows their full name and department.
const row = await waitFor(() => rowForUser("storybook_test_user"));
const rowScope = within(row);
expect(rowScope.getByText("Storybook Test")).toBeInTheDocument();
expect(rowScope.getByText("Clinical Pharmacology")).toBeInTheDocument();

// No "remove access" button for the current user's own row.
expect(rowScope.queryByRole("button")).not.toBeInTheDocument();
},
};

export const SearchThreshold: Story = {
play: async ({ userEvent }) => {
const combobox = await screen.findByRole("combobox", { name: /Add user/i });
await userEvent.click(combobox);

// Under 3 characters: prompt shown, no options offered.
await userEvent.type(combobox, "al");
expect(
await screen.findByText("Type at least 3 characters to search"),
).toBeInTheDocument();
expect(screen.queryByRole("option")).not.toBeInTheDocument();

// At 3 characters: matching users are offered, labelled "<username> (<name>)".
await userEvent.type(combobox, "i");
expect(
await screen.findByRole("option", { name: /asmith \(Alice Smith\)/ }),
).toBeInTheDocument();
},
};

export const AddUser: Story = {
play: async ({ userEvent }) => {
const combobox = await screen.findByRole("combobox", { name: /Add user/i });
await userEvent.click(combobox);
await userEvent.type(combobox, "asm");

const option = await screen.findByRole("option", {
name: /asmith \(Alice Smith\)/,
});
await userEvent.click(option);

// The added user now appears in the table with name + department and a
// remove button (it is not the current user).
const row = await waitFor(() => rowForUser("asmith"));
const rowScope = within(row);
expect(rowScope.getByText("Alice Smith")).toBeInTheDocument();
expect(rowScope.getByText("Pharmacometrics")).toBeInTheDocument();
expect(rowScope.getByRole("button")).toBeInTheDocument();
},
};

export const RemoveUser: Story = {
play: async ({ context, userEvent }) => {
// Start from the added-user state.
await AddUser.play?.(context);

const row = await waitFor(() => rowForUser("asmith"));
await userEvent.click(within(row).getByRole("button"));

await waitFor(() =>
expect(screen.queryByText("asmith")).not.toBeInTheDocument(),
);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export const users = [
email: "test@pkpdapp.com",
profile: {
id: 1,
department: "",
user: 1
},
project_set: [
Expand Down
1 change: 1 addition & 0 deletions frontend-v2/src/stories/generated-mocks/project.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const users = [
email: "test@pkpdapp.com",
profile: {
id: 1,
department: "",
user: 1
},
project_set: [
Expand Down
Loading
Loading