From 022570e70d0ef8d0d8fb326614c31d7558e02c38 Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Wed, 5 Aug 2026 17:45:04 +0100 Subject: [PATCH 1/7] feat: add user details and search for predilogin --- pkpdapp/pkpdapp/predilogin.py | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/pkpdapp/pkpdapp/predilogin.py b/pkpdapp/pkpdapp/predilogin.py index 3ce8f750f..0a46f7681 100644 --- a/pkpdapp/pkpdapp/predilogin.py +++ b/pkpdapp/pkpdapp/predilogin.py @@ -9,11 +9,22 @@ from django.conf import settings from django.contrib.auth.models import User +from dataclasses import dataclass +from typing import List + import requests import logging logger = logging.getLogger(__name__) + +@dataclass +class UserSearchResult: + email: str + first_name: str + last_name: str + username: str + UserModel = get_user_model() API_KEY = settings.AUTH_PREDILOGIN_API_KEY @@ -49,6 +60,29 @@ def check_groupmembership(userid: str, group: str) -> bool: return any(member["userId"] == userid for member in members) if members else False +def get_user_details(username: str) -> dict: + logger.info(f"Fetching user details for: {username}") + endpoint = BASE_URL + "/v2.0/users/details" + headers = {"Content-Type": "application/json", "X-Gravitee-Api-Key": API_KEY} + body = {"id": username} + try: + response = requests.post(endpoint, headers=headers, json=body, verify=False) + if response.status_code != 200: + logger.warning( + f"Failed to fetch user details for {username}: {response.status_code}" + ) + return {} + data = response.json() + return { + "email": data.get("email", ""), + "first_name": data.get("firstName", ""), + "last_name": data.get("lastName", ""), + } + except (requests.RequestException, ValueError) as e: + logger.warning(f"Error fetching user details for {username}: {e}") + return {} + + class PrediBackend(BaseBackend): """ Authenticates against settings.AUTH_USER_MODEL. @@ -74,6 +108,14 @@ def authenticate(self, request, username=None, password=None, **kwargs): logger.info(f"User not found, creating new user: {username}") user = User(username=username) + details = get_user_details(username) + if details.get("email"): + user.email = details["email"] + if details.get("first_name"): + user.first_name = details["first_name"] + if details.get("last_name"): + user.last_name = details["last_name"] + user.set_password(password) user.is_staff = is_superuser user.is_superuser = is_superuser @@ -109,3 +151,30 @@ def get_user_permissions(self, user_obj, obj=None): def get_group_permissions(self, user_obj, obj=None): logger.debug(f"Getting group permissions for: {user_obj.username}") return user_obj.get_group_permissions() + + def search_users(self, q: str) -> List[UserSearchResult]: + logger.info(f"Searching users with query: {q}") + endpoint = BASE_URL + "/v2.0/users/search" + headers = {"Content-Type": "application/json", "X-Gravitee-Api-Key": API_KEY} + try: + response = requests.get( + endpoint, headers=headers, params={"q": q}, verify=False + ) + if response.status_code != 200: + logger.warning( + f"Failed to search users for '{q}': {response.status_code}" + ) + return [] + results = response.json() + return [ + UserSearchResult( + email=user.get("email", ""), + first_name=user.get("firstName", ""), + last_name=user.get("lastName", ""), + username=user.get("userId", ""), + ) + for user in results + ] + except (requests.RequestException, ValueError) as e: + logger.warning(f"Error searching users for '{q}': {e}") + return [] From 524bbf5412dbc0d42f5a8801900faf159ce5ce86 Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Wed, 5 Aug 2026 17:56:46 +0100 Subject: [PATCH 2/7] fix: predilogin --- pkpdapp/pkpdapp/predilogin.py | 58 +++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/pkpdapp/pkpdapp/predilogin.py b/pkpdapp/pkpdapp/predilogin.py index 0a46f7681..85e37c747 100644 --- a/pkpdapp/pkpdapp/predilogin.py +++ b/pkpdapp/pkpdapp/predilogin.py @@ -64,9 +64,10 @@ def get_user_details(username: str) -> dict: logger.info(f"Fetching user details for: {username}") endpoint = BASE_URL + "/v2.0/users/details" headers = {"Content-Type": "application/json", "X-Gravitee-Api-Key": API_KEY} - body = {"id": username} try: - response = requests.post(endpoint, headers=headers, json=body, verify=False) + response = requests.post( + endpoint, headers=headers, params={"id": username}, verify=False + ) if response.status_code != 200: logger.warning( f"Failed to fetch user details for {username}: {response.status_code}" @@ -83,6 +84,32 @@ def get_user_details(username: str) -> dict: return {} +def search_users(q: str) -> List[UserSearchResult]: + logger.info(f"Searching users with query: {q}") + endpoint = BASE_URL + "/v2.0/users/search" + headers = {"Content-Type": "application/json", "X-Gravitee-Api-Key": API_KEY} + try: + response = requests.get( + endpoint, headers=headers, params={"q": q}, verify=False + ) + if response.status_code != 200: + logger.warning(f"Failed to search users for '{q}': {response.status_code}") + return [] + results = response.json() + return [ + UserSearchResult( + email=user.get("email", ""), + first_name=user.get("firstName", ""), + last_name=user.get("lastName", ""), + username=user.get("userId", ""), + ) + for user in results + ] + except (requests.RequestException, ValueError) as e: + logger.warning(f"Error searching users for '{q}': {e}") + return [] + + class PrediBackend(BaseBackend): """ Authenticates against settings.AUTH_USER_MODEL. @@ -151,30 +178,3 @@ def get_user_permissions(self, user_obj, obj=None): def get_group_permissions(self, user_obj, obj=None): logger.debug(f"Getting group permissions for: {user_obj.username}") return user_obj.get_group_permissions() - - def search_users(self, q: str) -> List[UserSearchResult]: - logger.info(f"Searching users with query: {q}") - endpoint = BASE_URL + "/v2.0/users/search" - headers = {"Content-Type": "application/json", "X-Gravitee-Api-Key": API_KEY} - try: - response = requests.get( - endpoint, headers=headers, params={"q": q}, verify=False - ) - if response.status_code != 200: - logger.warning( - f"Failed to search users for '{q}': {response.status_code}" - ) - return [] - results = response.json() - return [ - UserSearchResult( - email=user.get("email", ""), - first_name=user.get("firstName", ""), - last_name=user.get("lastName", ""), - username=user.get("userId", ""), - ) - for user in results - ] - except (requests.RequestException, ValueError) as e: - logger.warning(f"Error searching users for '{q}': {e}") - return [] From feeeba8d880830ac94a1de7206c6f11963f4fafe Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Wed, 5 Aug 2026 18:47:24 +0100 Subject: [PATCH 3/7] fix: add department to user from predilogin --- .../migrations/0073_profile_department.py | 18 ++++++++++++++++++ pkpdapp/pkpdapp/models/profile.py | 6 ++++++ pkpdapp/pkpdapp/predilogin.py | 9 +++++++++ 3 files changed, 33 insertions(+) create mode 100644 pkpdapp/pkpdapp/migrations/0073_profile_department.py diff --git a/pkpdapp/pkpdapp/migrations/0073_profile_department.py b/pkpdapp/pkpdapp/migrations/0073_profile_department.py new file mode 100644 index 000000000..d5e61a12e --- /dev/null +++ b/pkpdapp/pkpdapp/migrations/0073_profile_department.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-08-05 17:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pkpdapp', '0072_update_models'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='department', + field=models.CharField(blank=True, default='', help_text="the user's department", max_length=100), + ), + ] diff --git a/pkpdapp/pkpdapp/models/profile.py b/pkpdapp/pkpdapp/models/profile.py index 9dd62acdd..79fbf68a4 100644 --- a/pkpdapp/pkpdapp/models/profile.py +++ b/pkpdapp/pkpdapp/models/profile.py @@ -14,3 +14,9 @@ class Profile(models.Model): :model:`auth.User`. """ user = models.OneToOneField(User, on_delete=models.CASCADE) + department = models.CharField( + max_length=100, + blank=True, + default="", + help_text="the user's department", + ) diff --git a/pkpdapp/pkpdapp/predilogin.py b/pkpdapp/pkpdapp/predilogin.py index 85e37c747..cb1618bdd 100644 --- a/pkpdapp/pkpdapp/predilogin.py +++ b/pkpdapp/pkpdapp/predilogin.py @@ -9,6 +9,8 @@ from django.conf import settings from django.contrib.auth.models import User +from pkpdapp.models import Profile + from dataclasses import dataclass from typing import List @@ -78,6 +80,7 @@ def get_user_details(username: str) -> dict: "email": data.get("email", ""), "first_name": data.get("firstName", ""), "last_name": data.get("lastName", ""), + "department": data.get("department", ""), } except (requests.RequestException, ValueError) as e: logger.warning(f"Error fetching user details for {username}: {e}") @@ -148,6 +151,12 @@ def authenticate(self, request, username=None, password=None, **kwargs): user.is_superuser = is_superuser user.is_active = is_superuser or is_user user.save() + + if details.get("department"): + profile, _ = Profile.objects.get_or_create(user=user) + profile.department = details["department"] + profile.save() + if not user.is_active: user = None return user From 6155056f1a8102047aec3ac69d306f6e1795a50c Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Wed, 5 Aug 2026 19:02:12 +0100 Subject: [PATCH 4/7] feat: add search for user access --- frontend-v2/src/app/backendApi.ts | 4 + .../src/features/projects/UserAccess.tsx | 88 +++++++++++++++---- pkpdapp/schema.yml | 4 + 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/frontend-v2/src/app/backendApi.ts b/frontend-v2/src/app/backendApi.ts index 36cf46190..f8bfbcc41 100644 --- a/frontend-v2/src/app/backendApi.ts +++ b/frontend-v2/src/app/backendApi.ts @@ -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 = { diff --git a/frontend-v2/src/features/projects/UserAccess.tsx b/frontend-v2/src/features/projects/UserAccess.tsx index 4222e9850..dc3b09787 100644 --- a/frontend-v2/src/features/projects/UserAccess.tsx +++ b/frontend-v2/src/features/projects/UserAccess.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { Dialog, DialogContent, @@ -13,6 +13,9 @@ import { Box, Button, Typography, + Autocomplete, + TextField, + createFilterOptions, } from "@mui/material"; import { ProjectAccess, @@ -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; @@ -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(); + const UserAccess: FC = ({ open, userAccess, @@ -46,6 +67,7 @@ const UserAccess: FC = ({ onClose, }) => { const { data: users } = useUserListQuery(); + const [inputValue, setInputValue] = useState(""); // create map from user id to user object const userMap = new Map(); @@ -65,13 +87,16 @@ const UserAccess: FC = ({ 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 ( @@ -85,16 +110,24 @@ const UserAccess: FC = ({ Username + Name + Department Remove Access {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 ( {userName} + {name} + {department} {!isMe && ( @@ -107,15 +140,38 @@ const UserAccess: FC = ({ })} - - Add user - + 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) => ( + + )} + /> Date: Thu, 6 Aug 2026 13:22:30 +0100 Subject: [PATCH 5/7] feat: add management command to update users from predilogin --- .../commands/update_users_from_predilogin.py | 53 +++++++++++++++++++ start-server-dev.sh | 43 +++++++++------ 2 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 pkpdapp/pkpdapp/management/commands/update_users_from_predilogin.py diff --git a/pkpdapp/pkpdapp/management/commands/update_users_from_predilogin.py b/pkpdapp/pkpdapp/management/commands/update_users_from_predilogin.py new file mode 100644 index 000000000..21d24fa23 --- /dev/null +++ b/pkpdapp/pkpdapp/management/commands/update_users_from_predilogin.py @@ -0,0 +1,53 @@ +# +# This file is part of PKPDApp (https://github.com/pkpdapp-team/pkpdapp) which +# is released under the BSD 3-clause license. See accompanying LICENSE.md for +# copyright notice and full license details. +# +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand + +from pkpdapp.models import Profile + +PREDI_BACKEND = "pkpdapp.predilogin.PrediBackend" + + +class Command(BaseCommand): + help = ( + "Update every user's full name, email and department from PrediLogin. " + "Only runs when the deployment authenticates against PrediLogin " + "(PrediBackend in AUTHENTICATION_BACKENDS); otherwise it is a no-op." + ) + + def handle(self, **options): + if PREDI_BACKEND not in settings.AUTHENTICATION_BACKENDS: + self.stdout.write( + "PrediLogin is not enabled; leaving users unchanged." + ) + return + + # Imported here: predilogin reads AUTH_PREDILOGIN_* settings at import + # time, which only exist when PrediLogin is enabled. + from pkpdapp.predilogin import get_user_details + + for user in User.objects.all(): + details = get_user_details(user.username) + if not details: + self.stdout.write( + f"No details found for {user.username}; skipping." + ) + continue + if details.get("email"): + user.email = details["email"] + if details.get("first_name"): + user.first_name = details["first_name"] + if details.get("last_name"): + user.last_name = details["last_name"] + user.save() + + if details.get("department"): + profile, _ = Profile.objects.get_or_create(user=user) + profile.department = details["department"] + profile.save() + + self.stdout.write(f"Updated user from PrediLogin: {user.username}") diff --git a/start-server-dev.sh b/start-server-dev.sh index 81567cc82..a418779aa 100755 --- a/start-server-dev.sh +++ b/start-server-dev.sh @@ -48,25 +48,34 @@ cd "$BACKEND_DIR" echo -e "${GREEN}[Backend]${NC} Running migrations..." python manage.py migrate --no-input -# Create test user -echo -e "${GREEN}[Backend]${NC} Creating test user (username: test, password: test)..." +# Create test users +echo -e "${GREEN}[Backend]${NC} Creating test users (password matches username)..." python manage.py shell -c " from django.contrib.auth import get_user_model; +from pkpdapp.models import Profile; User = get_user_model(); -user, created = User.objects.get_or_create( - username='test', - defaults={'email': 'test@example.com'} -); -user.email = 'test@example.com'; -user.is_staff = False; -user.is_superuser = False; -user.set_password('test'); -user.save(); -if created: - print('Test user created'); -else: - print('Test user updated to a normal user'); -" 2>/dev/null || echo "Note: Could not create test user (may already exist)" +test_users = [ + ('test', 'test@example.com', 'Test', 'User', 'Clinical Pharmacology'), + ('alice', 'alice@example.com', 'Alice', 'Anderson', 'Pharmacometrics'), + ('bob', 'bob@example.com', 'Bob', 'Baker', 'Drug Metabolism'), +]; +for username, email, first_name, last_name, department in test_users: + user, created = User.objects.get_or_create( + username=username, + defaults={'email': email} + ); + user.email = email; + user.first_name = first_name; + user.last_name = last_name; + user.is_staff = False; + user.is_superuser = False; + user.set_password(username); + user.save(); + profile, _ = Profile.objects.get_or_create(user=user); + profile.department = department; + profile.save(); + print(('Created ' if created else 'Updated ') + username); +" 2>/dev/null || echo "Note: Could not create test users (may already exist)" # Start Django development server in background echo -e "${GREEN}[Backend]${NC} Starting Django dev server on http://127.0.0.1:8000..." @@ -97,7 +106,7 @@ echo -e "${GREEN}========================================${NC}" echo -e "${BLUE}Frontend:${NC} http://127.0.0.1:3000" echo -e "${GREEN}Backend:${NC} http://127.0.0.1:8000" echo -e "${GREEN}Admin:${NC} http://127.0.0.1:8000/admin" -echo -e "\n${YELLOW}Test Login:${NC} username=test, password=test" +echo -e "\n${YELLOW}Test Logins:${NC} test/test, alice/alice, bob/bob (password = username)" echo -e "\n${YELLOW}Press Ctrl+C to stop all servers${NC}" echo -e "${GREEN}========================================${NC}\n" From 2348f1c0d0cddf77bac17b7ccdb465e3281c02c5 Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Thu, 6 Aug 2026 14:09:25 +0100 Subject: [PATCH 6/7] fix: satisfy flake8 and regenerate storybook mocks - predilogin.py: add missing blank line after UserSearchResult dataclass (E305) - 0073_profile_department: wrap CharField args to stay under 88 chars (E501) - regenerate storybook mocks to include the new Profile.department field Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/stories/generated-mocks-with-data/project.mock.ts | 1 + frontend-v2/src/stories/generated-mocks/project.mock.ts | 1 + pkpdapp/pkpdapp/migrations/0073_profile_department.py | 7 ++++++- pkpdapp/pkpdapp/predilogin.py | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend-v2/src/stories/generated-mocks-with-data/project.mock.ts b/frontend-v2/src/stories/generated-mocks-with-data/project.mock.ts index a28a00c34..0f0bb3a4e 100644 --- a/frontend-v2/src/stories/generated-mocks-with-data/project.mock.ts +++ b/frontend-v2/src/stories/generated-mocks-with-data/project.mock.ts @@ -71,6 +71,7 @@ export const users = [ email: "test@pkpdapp.com", profile: { id: 1, + department: "", user: 1 }, project_set: [ diff --git a/frontend-v2/src/stories/generated-mocks/project.mock.ts b/frontend-v2/src/stories/generated-mocks/project.mock.ts index 55940e16c..6d455a399 100644 --- a/frontend-v2/src/stories/generated-mocks/project.mock.ts +++ b/frontend-v2/src/stories/generated-mocks/project.mock.ts @@ -67,6 +67,7 @@ export const users = [ email: "test@pkpdapp.com", profile: { id: 1, + department: "", user: 1 }, project_set: [ diff --git a/pkpdapp/pkpdapp/migrations/0073_profile_department.py b/pkpdapp/pkpdapp/migrations/0073_profile_department.py index d5e61a12e..e6dce75ea 100644 --- a/pkpdapp/pkpdapp/migrations/0073_profile_department.py +++ b/pkpdapp/pkpdapp/migrations/0073_profile_department.py @@ -13,6 +13,11 @@ class Migration(migrations.Migration): migrations.AddField( model_name='profile', name='department', - field=models.CharField(blank=True, default='', help_text="the user's department", max_length=100), + field=models.CharField( + blank=True, + default='', + help_text="the user's department", + max_length=100, + ), ), ] diff --git a/pkpdapp/pkpdapp/predilogin.py b/pkpdapp/pkpdapp/predilogin.py index cb1618bdd..ad4231ea4 100644 --- a/pkpdapp/pkpdapp/predilogin.py +++ b/pkpdapp/pkpdapp/predilogin.py @@ -27,6 +27,7 @@ class UserSearchResult: last_name: str username: str + UserModel = get_user_model() API_KEY = settings.AUTH_PREDILOGIN_API_KEY From 3c278f7f8feb94d5325aa34379c6e9784671094b Mon Sep 17 00:00:00 2001 From: Martin Robinson Date: Thu, 6 Aug 2026 14:32:39 +0100 Subject: [PATCH 7/7] test: UserAccess storybook --- .../src/stories/UserAccess.stories.tsx | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 frontend-v2/src/stories/UserAccess.stories.tsx diff --git a/frontend-v2/src/stories/UserAccess.stories.tsx b/frontend-v2/src/stories/UserAccess.stories.tsx new file mode 100644 index 000000000..72a6275f3 --- /dev/null +++ b/frontend-v2/src/stories/UserAccess.stories.tsx @@ -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({ + defaultValues: { project, compound }, + }); + const { + fields: userAccess, + append, + remove, + } = useFieldArray({ + control, + name: "project.user_access", + }); + return ( + {}} + onClose={() => {}} + /> + ); +}; + +const meta: Meta = { + 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 ; + }, + ], + render: () => , +}; + +export default meta; +type Story = StoryObj; + +// 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 " ()". + 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(), + ); + }, +};