diff --git a/libs/tup-components/src/projects/users/UserList/AddUserModal.module.css b/libs/tup-components/src/projects/users/UserList/AddUserModal.module.css
new file mode 100644
index 000000000..14338521f
--- /dev/null
+++ b/libs/tup-components/src/projects/users/UserList/AddUserModal.module.css
@@ -0,0 +1,32 @@
+/* To prevent dynamic height adjustment */
+.root :global(.modal-content) {
+ height: 50vh;
+}
+
+/* To activate scrolling from SectionTableWrapper contentShouldScroll */
+/* NOTE: I do not think this should be required in client CSS */
+/* HELP: What other solutions exist? If not, then…
+ should SectionTableWrapper apply this? */
+.table-wrap {
+ overflow-y: auto;
+}
+.body {
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+}
+
+.add-remove-column {
+ text-align: right;
+
+ /* To "shrink-wrap" table cell */
+ /* CAVEAT: Requires table `table-layout: auto` (browser default) */
+ width: 1%;
+ white-space: nowrap;
+}
+
+.success-icon {
+ margin-right: 0.5rem;
+ vertical-align: text-top;
+
+ color: var(--global-color-success--normal);
+}
diff --git a/libs/tup-components/src/projects/users/UserList/AddUserModal.test.tsx b/libs/tup-components/src/projects/users/UserList/AddUserModal.test.tsx
new file mode 100644
index 000000000..25f0e9c8c
--- /dev/null
+++ b/libs/tup-components/src/projects/users/UserList/AddUserModal.test.tsx
@@ -0,0 +1,27 @@
+import AddUserModal from './AddUserModal';
+import { testRender } from '@tacc/tup-testing';
+import { screen, fireEvent, within } from '@testing-library/react';
+
+describe('AddUserModal', () => {
+ it('should display search results', async () => {
+ testRender();
+ const modalButton = screen.getByRole('button');
+ // open the modal
+ fireEvent.click(modalButton);
+
+ const searchButton = screen.getByText('Search');
+ fireEvent.click(searchButton);
+ const rows = await screen.findAllByRole('row');
+ expect(rows.length).toBe(3);
+
+ // A user in the project should display as added already
+ const existingUserRow = rows[1];
+ const rowQuery = await within(existingUserRow).findByText(/Added/);
+ expect(rowQuery).toBeDefined();
+
+ // A user who is not in the project should display a prompt.
+ const newUserRow = rows[2];
+ const rowQuery2 = await within(newUserRow).findByText(/Add User/);
+ expect(rowQuery2).toBeDefined();
+ });
+});
diff --git a/libs/tup-components/src/projects/users/UserList/AddUserModal.tsx b/libs/tup-components/src/projects/users/UserList/AddUserModal.tsx
new file mode 100644
index 000000000..61271a47b
--- /dev/null
+++ b/libs/tup-components/src/projects/users/UserList/AddUserModal.tsx
@@ -0,0 +1,222 @@
+import {
+ Button,
+ LoadingSpinner,
+ Icon,
+ SectionMessage,
+ SectionTableWrapper,
+} from '@tacc/core-components';
+import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
+import { Input } from 'reactstrap';
+import React, { useState } from 'react';
+import {
+ useUserLookup,
+ UserSearchResult,
+ useProjectUsers,
+ useAddProjectUser,
+ useRemoveProjectUser,
+} from '@tacc/tup-hooks';
+
+import styles from './AddUserModal.module.css';
+import stylesUserList from './UserList.module.css';
+
+type FieldValue = 'email' | 'username' | 'last_name';
+
+const AddUserButton: React.FC<{ username: string; projectId: number }> = ({
+ username,
+ projectId,
+}) => {
+ const { mutate, isLoading } = useAddProjectUser(projectId);
+ if (isLoading) return ;
+ return (
+
+ );
+};
+
+const RemoveUser: React.FC<{ username: string; projectId: number }> = ({
+ username,
+ projectId,
+}) => {
+ const { mutate, isLoading } = useRemoveProjectUser(projectId, username);
+ if (isLoading)
+ return (
+
+
+
+ );
+ return (
+ <>
+ {' '}
+ Added |
+
+ >
+ );
+};
+
+const UserSearchTable: React.FC<{
+ users: UserSearchResult[];
+ projectId: number;
+}> = ({ users, projectId }) => {
+ const { data: projectUsers } = useProjectUsers(projectId);
+
+ const userInProject = (username: string) => {
+ return (projectUsers || []).some((user) => user.username === username);
+ };
+
+ if (!users.length)
+ return (
+
+ No users matching your query could be found.
+
+ );
+
+ return (
+
+
+
+ Name |
+ Email |
+ Username |
+ |
+
+
+
+ {users.map((user) => (
+
+ {user.name} |
+ {user.email} |
+ {user.username} |
+
+ {userInProject(user.username) ? (
+
+ ) : (
+
+ )}
+ |
+
+ ))}
+
+
+ );
+};
+
+const AddUserModal: React.FC<{ projectId: number }> = ({ projectId }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const toggle = () => {
+ setIsOpen(!isOpen);
+ setField('last_name');
+ setQuery('');
+ };
+
+ const [field, setField] = useState('last_name');
+ const [query, setQuery] = useState('');
+ const { data, isFetching, refetch } = useUserLookup(projectId, query, field);
+
+ const onSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ refetch();
+ };
+
+ const closeBtn = (
+
+ );
+
+ return (
+ <>
+
+
+
+ Add Users
+
+
+ Search for User
+
+ {/* Search result table */}
+ {data && (
+
+
+
+ )}
+
+
+
+ >
+ );
+};
+
+export default AddUserModal;
diff --git a/libs/tup-components/src/projects/users/UserList/ManageTeam.tsx b/libs/tup-components/src/projects/users/UserList/ManageTeam.tsx
index 898c2cde2..1d2a50cd8 100644
--- a/libs/tup-components/src/projects/users/UserList/ManageTeam.tsx
+++ b/libs/tup-components/src/projects/users/UserList/ManageTeam.tsx
@@ -1,6 +1,7 @@
import { Button } from '@tacc/core-components';
import { useProject, useRoleForCurrentUser } from '@tacc/tup-hooks';
import styles from './UserList.module.css';
+import AddUserModal from './AddUserModal';
const ManageTeam: React.FC<{ projectId: number }> = ({ projectId }) => {
const currentUserRole = useRoleForCurrentUser(projectId) ?? '';
@@ -25,13 +26,7 @@ const ManageTeam: React.FC<{ projectId: number }> = ({ projectId }) => {
return (
);
};