diff --git a/src/api/schema/typePolicies/typePolicies.base.ts b/src/api/schema/typePolicies/typePolicies.base.ts
index 90b95800b9..5849a9acbf 100644
--- a/src/api/schema/typePolicies/typePolicies.base.ts
+++ b/src/api/schema/typePolicies/typePolicies.base.ts
@@ -85,6 +85,12 @@ export const typePolicies: TypePolicies = {
engagements: {},
},
},
+ User: {
+ fields: {
+ projects: {}, // no page merging (infinite scroll)
+ partners: {}, // no page merging (infinite scroll)
+ },
+ },
Query: {
fields: {
projects: {},
diff --git a/src/components/Grid/Footer.tsx b/src/components/Grid/Footer.tsx
new file mode 100644
index 0000000000..01e39a3704
--- /dev/null
+++ b/src/components/Grid/Footer.tsx
@@ -0,0 +1,22 @@
+import { Stack } from '@mui/material';
+import { ChildrenProp, extendSx, StyleProps } from '~/common';
+
+export const Footer = (props: ChildrenProp & StyleProps) => {
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/src/components/PartnersDataGrid/PartnerColumns.tsx b/src/components/PartnersDataGrid/PartnerColumns.tsx
index 4357632875..b8de9a25a9 100644
--- a/src/components/PartnersDataGrid/PartnerColumns.tsx
+++ b/src/components/PartnersDataGrid/PartnerColumns.tsx
@@ -102,6 +102,7 @@ export const PartnerInitialState = {
...getInitialVisibility(PartnerColumns),
isMember: false,
pinned: false,
+ actions: false,
},
},
} satisfies DataGridProps['initialState'];
diff --git a/src/components/PartnersDataGrid/index.ts b/src/components/PartnersDataGrid/index.ts
new file mode 100644
index 0000000000..7842ee9d0c
--- /dev/null
+++ b/src/components/PartnersDataGrid/index.ts
@@ -0,0 +1,2 @@
+export * from './PartnerColumns';
+export * from './partnerDataGridRow.graphql';
diff --git a/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUser.graphql b/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUser.graphql
new file mode 100644
index 0000000000..b675bfa057
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUser.graphql
@@ -0,0 +1,11 @@
+mutation AssignOrganizationToUser($input: AssignOrganizationToUserInput!) {
+ assignOrganizationToUser(input: $input) {
+ partner {
+ ...userPartnerDataGridRow
+ }
+ }
+}
+
+fragment AssignOrganizationToUserForm on User {
+ ...Id
+}
diff --git a/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUserForm.tsx b/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUserForm.tsx
new file mode 100644
index 0000000000..1f471db86e
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Partners/AssignOrgToUserForm/AssignOrgToUserForm.tsx
@@ -0,0 +1,60 @@
+import { useMutation } from '@apollo/client';
+import { Except } from 'type-fest';
+import { addItemToList } from '~/api';
+import { DialogForm, DialogFormProps } from '~/components/Dialog/DialogForm';
+import { CheckboxField, SubmitError } from '~/components/form';
+import { OrganizationField } from '~/components/form/Lookup';
+import { OrganizationLookupItemFragment } from '~/components/form/Lookup/Organization/OrganizationLookup.graphql';
+import {
+ AssignOrganizationToUserDocument,
+ AssignOrganizationToUserFormFragment,
+} from './AssignOrgToUser.graphql';
+
+interface AssignOrgToUserFormValues {
+ assignment: {
+ orgId: OrganizationLookupItemFragment | null;
+ primary?: boolean;
+ };
+}
+
+type AssignOrgToUserFormProps = Except<
+ DialogFormProps,
+ 'onSubmit' | 'initialValues'
+> & {
+ user: AssignOrganizationToUserFormFragment;
+};
+
+export const AssignOrgToUserForm = ({
+ user,
+ ...props
+}: AssignOrgToUserFormProps) => {
+ const [assignOrgToUser] = useMutation(AssignOrganizationToUserDocument, {
+ update: addItemToList({
+ listId: [user, 'partners'],
+ outputToItem: (data) => data.assignOrganizationToUser.partner,
+ }),
+ });
+
+ return (
+
+ title="Assign Organization to User"
+ {...props}
+ onSubmit={async ({ assignment }) => {
+ const input = {
+ assignment: {
+ userId: user.id,
+ orgId: assignment.orgId!.id,
+ primary: assignment.primary ?? false,
+ },
+ };
+
+ await assignOrgToUser({ variables: { input } });
+ }}
+ fieldsPrefix="assignment"
+ >
+
+
+
+
+ );
+};
diff --git a/src/scenes/Users/Detail/Tabs/Partners/RemoveOrgFromUser.graphql b/src/scenes/Users/Detail/Tabs/Partners/RemoveOrgFromUser.graphql
new file mode 100644
index 0000000000..14872906b1
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Partners/RemoveOrgFromUser.graphql
@@ -0,0 +1,11 @@
+mutation RemoveOrganizationFromUser($input: RemoveOrganizationFromUserInput!) {
+ removeOrganizationFromUser(input: $input) {
+ partner {
+ ...userPartnerDataGridRow
+ }
+ }
+}
+
+fragment RemoveOrganizationFromUserForm on User {
+ ...Id
+}
diff --git a/src/scenes/Users/Detail/Tabs/Partners/UserDetailPartners.tsx b/src/scenes/Users/Detail/Tabs/Partners/UserDetailPartners.tsx
new file mode 100644
index 0000000000..9607b37d95
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Partners/UserDetailPartners.tsx
@@ -0,0 +1,148 @@
+import { useMutation } from '@apollo/client/react/hooks/useMutation';
+import { Add, Delete } from '@mui/icons-material';
+import { Button, Tooltip } from '@mui/material';
+import {
+ DataGridPro as DataGrid,
+ DataGridProProps as DataGridProps,
+ GridActionsCellItem,
+} from '@mui/x-data-grid-pro';
+import { merge } from 'lodash';
+import { useCallback, useMemo } from 'react';
+import { removeItemFromList } from '~/api';
+import { useDialog } from '~/components/Dialog';
+import {
+ DefaultDataGridStyles,
+ flexLayout,
+ noHeaderFilterButtons,
+ useDataGridSource,
+} from '~/components/Grid';
+import { Footer } from '~/components/Grid/Footer';
+import {
+ PartnerDataGridRowFragment as Partner,
+ PartnerColumns,
+ PartnerInitialState,
+ PartnerToolbar,
+} from '~/components/PartnersDataGrid';
+import { TabPanelContent } from '~/components/Tabs/TabPanelContent';
+import { AssignOrganizationToUserFormFragment } from './AssignOrgToUserForm/AssignOrgToUser.graphql';
+import { AssignOrgToUserForm } from './AssignOrgToUserForm/AssignOrgToUserForm';
+import { RemoveOrganizationFromUserDocument } from './RemoveOrgFromUser.graphql';
+import { UserPartnerListDocument } from './UserPartnerList.graphql';
+
+interface UserDetailPartnersProps {
+ user: AssignOrganizationToUserFormFragment;
+}
+
+export const UserDetailPartners = ({ user }: UserDetailPartnersProps) => {
+ const [dataGridProps] = useDataGridSource({
+ query: UserPartnerListDocument,
+ variables: { userId: user.id },
+ listAt: 'user.partners',
+ initialInput: {
+ sort: 'organization.name',
+ },
+ });
+
+ const [addPartnerState, addPartner] = useDialog();
+
+ const PartnerFooter = useCallback(
+ () => (
+
+ ),
+ [addPartner, dataGridProps.apiRef]
+ );
+
+ const slots = useMemo(
+ () =>
+ merge({}, DefaultDataGridStyles.slots, dataGridProps.slots, {
+ toolbar: PartnerToolbar,
+ footer: PartnerFooter,
+ } satisfies DataGridProps['slots']),
+ [dataGridProps.slots, PartnerFooter]
+ );
+
+ const slotProps = useMemo(
+ () => merge({}, DefaultDataGridStyles.slotProps, dataGridProps.slotProps),
+ [dataGridProps.slotProps]
+ );
+
+ const [removeOrgFromUser] = useMutation(RemoveOrganizationFromUserDocument);
+
+ const getActions = ({ row }: { row: Partner }) => [
+ }
+ label="Remove Partner"
+ onClick={() =>
+ void removeOrgFromUser({
+ variables: {
+ input: {
+ assignment: {
+ userId: user.id,
+ orgId: row.organization.value!.id,
+ },
+ },
+ },
+ update: removeItemFromList({
+ listId: [user, 'partners'],
+ item: row,
+ }),
+ })
+ }
+ color="error"
+ />,
+ ];
+
+ return (
+
+
+ {...dataGridProps}
+ {...DefaultDataGridStyles}
+ slots={slots}
+ slotProps={slotProps}
+ columns={[
+ ...PartnerColumns,
+ {
+ field: 'actions',
+ type: 'actions',
+ headerName: 'Remove',
+ width: 100,
+ hideable: false,
+ getActions,
+ },
+ ]}
+ initialState={PartnerInitialState}
+ headerFilters
+ sx={[flexLayout, noHeaderFilterButtons]}
+ />
+
+
+ );
+};
diff --git a/src/scenes/Users/Detail/Tabs/Partners/UserPartnerList.graphql b/src/scenes/Users/Detail/Tabs/Partners/UserPartnerList.graphql
new file mode 100644
index 0000000000..2ad9743af1
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Partners/UserPartnerList.graphql
@@ -0,0 +1,17 @@
+query UserPartnerList($userId: ID!, $input: PartnerListInput) {
+ user(id: $userId) {
+ id
+ partners(input: $input) {
+ canRead
+ hasMore
+ total
+ items {
+ ...userPartnerDataGridRow
+ }
+ }
+ }
+}
+
+fragment userPartnerDataGridRow on Partner {
+ ...partnerDataGridRow
+}
diff --git a/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.graphql b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.graphql
new file mode 100644
index 0000000000..dd01e9a010
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.graphql
@@ -0,0 +1,13 @@
+fragment UserProfile on User {
+ primaryOrganization {
+ organization {
+ value {
+ name {
+ value
+ }
+ }
+ }
+ }
+ ...DisplayUser
+ ...UserForm
+}
diff --git a/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.tsx b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.tsx
new file mode 100644
index 0000000000..b893d34777
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.tsx
@@ -0,0 +1,148 @@
+import { Edit } from '@mui/icons-material';
+import {
+ Box,
+ IconButton,
+ Paper,
+ Skeleton,
+ Stack,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import { useInterval } from 'ahooks';
+import { DateTime } from 'luxon';
+import { useState } from 'react';
+import { RoleLabels } from '~/api/schema.graphql';
+import { canEditAny, labelsFrom } from '~/common';
+import { useDialog } from '~/components/Dialog';
+import {
+ DisplaySimpleProperty,
+ DisplaySimplePropertyProps,
+} from '~/components/DisplaySimpleProperty';
+import { EditUser } from '../../../Edit';
+import { UserProfileFragment } from './UserDetailProfile.graphql';
+
+interface UserDetailProfileProps {
+ user: UserProfileFragment;
+}
+
+export const UserDetailProfile = ({ user }: UserDetailProfileProps) => {
+ const [editUserState, editUser] = useDialog();
+
+ const canEditAnyFields = canEditAny(user);
+
+ return (
+ ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ width: theme.breakpoints.values.md,
+ })}
+ >
+
+
+
+
+
+ ) : null
+ }
+ loading={!user}
+ />
+
+
+
+
+
+ {canEditAnyFields ? (
+
+
+
+
+
+ ) : null}
+
+
+
+ );
+};
+
+const LocalTime = ({ timezone }: { timezone?: string }) => {
+ const now = useNow();
+ const formatted = now.toLocaleString({
+ timeZone: timezone,
+ ...DateTime.TIME_SIMPLE,
+ timeZoneName: 'short',
+ });
+ return <>{formatted}>;
+};
+
+const useNow = (updateInterval = 1_000) => {
+ const [now, setNow] = useState(() => DateTime.local());
+ useInterval(() => {
+ setNow(DateTime.local());
+ }, updateInterval);
+ return now;
+};
+
+const DisplayProperty = (props: DisplaySimplePropertyProps) =>
+ !props.value && !props.loading ? null : (
+
+
+
+
+
+
+
+ >
+ ) : null
+ }
+ LabelProps={{
+ color: 'textSecondary',
+ variant: 'body2',
+ ...props.LabelProps,
+ }}
+ ValueProps={{
+ color: 'textPrimary',
+ ...props.ValueProps,
+ }}
+ />
+ );
diff --git a/src/scenes/Users/Detail/Tabs/Projects/UserDetailProjects.tsx b/src/scenes/Users/Detail/Tabs/Projects/UserDetailProjects.tsx
new file mode 100644
index 0000000000..bf1a12b89a
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Projects/UserDetailProjects.tsx
@@ -0,0 +1,10 @@
+import { TabPanelContent } from '~/components/Tabs';
+import { UserProjectsPanel } from './UserProjectPanel/UserProjectsPanel';
+
+export const UserDetailProjects = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectList.graphql b/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectList.graphql
new file mode 100644
index 0000000000..59896afe09
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectList.graphql
@@ -0,0 +1,23 @@
+query UserProjects($userId: ID!, $input: ProjectListInput) {
+ user(id: $userId) {
+ id
+ projects(input: $input) {
+ canRead
+ hasMore
+ total
+ items {
+ ...userProjectDataGridRow
+ }
+ }
+ }
+}
+
+fragment userProjectDataGridRow on Project {
+ membership(user: $userId) {
+ id
+ roles {
+ value
+ }
+ }
+ ...projectDataGridRow
+}
diff --git a/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectsPanel.tsx b/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectsPanel.tsx
new file mode 100644
index 0000000000..a22808f12b
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Projects/UserProjectPanel/UserProjectsPanel.tsx
@@ -0,0 +1,82 @@
+import {
+ DataGridPro as DataGrid,
+ DataGridProProps as DataGridProps,
+ GridColDef,
+} from '@mui/x-data-grid-pro';
+import { merge } from 'lodash';
+import { useMemo } from 'react';
+import { useParams } from 'react-router-dom';
+import { RoleLabels, RoleList } from '~/api/schema.graphql';
+import { unmatchedIndexThrow } from '~/common';
+import {
+ DefaultDataGridStyles,
+ flexLayout,
+ multiEnumColumn,
+ noFooter,
+ noHeaderFilterButtons,
+ useDataGridSource,
+} from '~/components/Grid';
+import {
+ ProjectColumns,
+ ProjectInitialState,
+ ProjectToolbar,
+} from '~/components/ProjectDataGrid';
+import {
+ UserProjectDataGridRowFragment as UserProject,
+ UserProjectsDocument,
+} from './UserProjectList.graphql';
+
+export const UserProjectsPanel = () => {
+ const { userId = '' } = useParams();
+
+ const [dataGridProps] = useDataGridSource({
+ query: UserProjectsDocument,
+ variables: { userId },
+ listAt: 'user.projects',
+ initialInput: {
+ sort: 'name',
+ },
+ });
+
+ const slots = useMemo(
+ () =>
+ merge({}, DefaultDataGridStyles.slots, dataGridProps.slots, {
+ toolbar: ProjectToolbar,
+ } satisfies DataGridProps['slots']),
+ [dataGridProps.slots]
+ );
+
+ const slotProps = useMemo(
+ () => merge({}, DefaultDataGridStyles.slotProps, dataGridProps.slotProps),
+ [dataGridProps.slotProps]
+ );
+
+ return (
+
+ {...DefaultDataGridStyles}
+ {...dataGridProps}
+ slots={slots}
+ slotProps={slotProps}
+ columns={UserProjectColumns}
+ initialState={ProjectInitialState}
+ headerFilters
+ hideFooter
+ sx={[flexLayout, noHeaderFilterButtons, noFooter]}
+ />
+ );
+};
+
+const UserProjectRoleColumn: GridColDef = {
+ field: 'user.membership',
+ headerName: 'Role',
+ width: 300,
+ ...multiEnumColumn(RoleList, RoleLabels),
+ valueGetter: (_, { membership }) => membership.roles.value,
+};
+
+const indexAfterName =
+ unmatchedIndexThrow(ProjectColumns.findIndex((c) => c.field === 'name')) + 1;
+
+const UserProjectColumns = (ProjectColumns as Array>)
+ // Add roles' column after name
+ .toSpliced(indexAfterName, 0, UserProjectRoleColumn);
diff --git a/src/scenes/Users/Detail/UserDetail.graphql b/src/scenes/Users/Detail/UserDetail.graphql
index 54c20d372c..849e2ad854 100644
--- a/src/scenes/Users/Detail/UserDetail.graphql
+++ b/src/scenes/Users/Detail/UserDetail.graphql
@@ -1,7 +1,6 @@
query User($userId: ID!) {
user(id: $userId) {
- ...DisplayUser
- ...UserForm
+ ...UserProfile
...TogglePin
}
}
diff --git a/src/scenes/Users/Detail/UserDetail.tsx b/src/scenes/Users/Detail/UserDetail.tsx
index 1bd24dfda0..a8409f5626 100644
--- a/src/scenes/Users/Detail/UserDetail.tsx
+++ b/src/scenes/Users/Detail/UserDetail.tsx
@@ -1,43 +1,37 @@
import { useQuery } from '@apollo/client';
-import { Edit } from '@mui/icons-material';
-import { Box, Skeleton, Stack, Tooltip, Typography } from '@mui/material';
-import { useInterval } from 'ahooks';
-import { DateTime } from 'luxon';
-import { useState } from 'react';
+import { TabContext, TabList, TabPanel } from '@mui/lab';
+import { Box, Skeleton, Stack, Typography } from '@mui/material';
import { Helmet } from 'react-helmet-async';
import { useParams } from 'react-router-dom';
import { PartialDeep } from 'type-fest';
-import { RoleLabels } from '~/api/schema.graphql';
-import { canEditAny, labelsFrom } from '~/common';
import { ToggleCommentsButton } from '~/components/Comments/ToggleCommentButton';
+import { Error } from '~/components/Error';
+import { Redacted } from '~/components/Redacted';
+import { Tab, TabsContainer } from '~/components/Tabs';
+import { TogglePinButton } from '~/components/TogglePinButton';
+import { EnumParam, makeQueryHandler, withDefault } from '~/hooks';
import { useComments } from '../../../components/Comments/CommentsContext';
-import { useDialog } from '../../../components/Dialog';
-import {
- DisplaySimpleProperty,
- DisplaySimplePropertyProps,
-} from '../../../components/DisplaySimpleProperty';
-import { IconButton } from '../../../components/IconButton';
-import { PartnerListItemCard } from '../../../components/PartnerListItemCard';
-import { Redacted } from '../../../components/Redacted';
-import { TogglePinButton } from '../../../components/TogglePinButton';
-import { EditUser } from '../Edit';
import { UsersQueryVariables } from '../List/users.graphql';
import { ImpersonationToggle } from './ImpersonationToggle';
+import { AssignOrganizationToUserFormFragment } from './Tabs/Partners/AssignOrgToUserForm/AssignOrgToUser.graphql';
+import { UserDetailPartners } from './Tabs/Partners/UserDetailPartners';
+import { UserDetailProfile } from './Tabs/Profile/UserDetailProfile';
+import { UserDetailProjects } from './Tabs/Projects/UserDetailProjects';
import { UserDocument } from './UserDetail.graphql';
+const useUserDetailsFilters = makeQueryHandler({
+ tab: withDefault(EnumParam(['profile', 'projects', 'partners']), 'profile'),
+});
+
export const UserDetail = () => {
const { userId = '' } = useParams();
const { data, error } = useQuery(UserDocument, {
variables: { userId },
});
useComments(userId);
-
- const [editUserState, editUser] = useDialog();
-
+ const [filters, setFilters] = useUserDetailsFilters();
const user = data?.user;
- const canEditAnyFields = canEditAny(user);
-
return (
{
overflowY: 'auto',
p: 4,
gap: 3,
- maxWidth: (theme) => theme.breakpoints.values.md,
+ flex: 1,
+ maxWidth: (theme) => theme.breakpoints.values.xl,
}}
>
- {error ? (
- Error loading person
- ) : (
+
+
+ {{
+ NotFound: 'Could not find user',
+ Default: 'Error loading user',
+ }}
+
+ {!error && (
<>
{
{!user ? (
@@ -78,13 +77,6 @@ export const UserDetail = () => {
)
)}
- {canEditAnyFields ? (
-
-
-
-
-
- ) : null}
{
-
-
-
-
-
- ) : null
- }
- loading={!user}
- />
-
-
- {user ? : null}
-
- {!!user?.partners.items.length && (
- <>
- Partners
-
- {user.partners.items.map((item) => (
-
- ))}
-
- >
- )}
+
+
+ setFilters({ ...filters, tab })}
+ aria-label="user navigation tabs"
+ variant="scrollable"
+ >
+
+
+
+
+
+ {user && }
+
+
+
+
+
+ {user && (
+
+ )}
+
+
+
>
)}
);
};
-
-const LocalTime = ({ timezone }: { timezone?: string }) => {
- const now = useNow();
- const formatted = now.toLocaleString({
- timeZone: timezone,
- ...DateTime.TIME_SIMPLE,
- timeZoneName: 'short',
- });
- return <>{formatted}>;
-};
-
-const useNow = (updateInterval = 1_000) => {
- const [now, setNow] = useState(() => DateTime.local());
- useInterval(() => {
- setNow(DateTime.local());
- }, updateInterval);
- return now;
-};
-
-const DisplayProperty = (props: DisplaySimplePropertyProps) =>
- !props.value && !props.loading ? null : (
-
-
-
-
-
-
-
- >
- ) : null
- }
- LabelProps={{
- color: 'textSecondary',
- variant: 'body2',
- ...props.LabelProps,
- }}
- ValueProps={{
- color: 'textPrimary',
- ...props.ValueProps,
- }}
- />
- );