diff --git a/src/api/schema/typePolicies/typePolicies.base.ts b/src/api/schema/typePolicies/typePolicies.base.ts
index 90b95800b..b41dfdf9f 100644
--- a/src/api/schema/typePolicies/typePolicies.base.ts
+++ b/src/api/schema/typePolicies/typePolicies.base.ts
@@ -85,6 +85,11 @@ export const typePolicies: TypePolicies = {
engagements: {},
},
},
+ User: {
+ fields: {
+ projects: {}, // no page merging (infinite scroll)
+ },
+ },
Query: {
fields: {
projects: {},
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 000000000..dcf0e8dd6
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.graphql
@@ -0,0 +1,4 @@
+fragment UserProfile on User {
+ ...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 000000000..45ed9e721
--- /dev/null
+++ b/src/scenes/Users/Detail/Tabs/Profile/UserDetailProfile.tsx
@@ -0,0 +1,157 @@
+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 { PartnerListItemCard } from '~/components/PartnerListItemCard';
+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}
+ />
+
+
+
+ {!!user.partners.items.length && (
+ <>
+ Partners
+
+ {user.partners.items.map((item) => (
+
+
+
+ ))}
+
+ >
+ )}
+
+
+ {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 000000000..bf1a12b89
--- /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 000000000..59896afe0
--- /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 000000000..a22808f12
--- /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.tsx b/src/scenes/Users/Detail/UserDetail.tsx
index 1bd24dfda..d9c3dc981 100644
--- a/src/scenes/Users/Detail/UserDetail.tsx
+++ b/src/scenes/Users/Detail/UserDetail.tsx
@@ -1,43 +1,35 @@
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 { UserDetailProfile } from './Tabs/Profile/UserDetailProfile';
+import { UserDetailProjects } from './Tabs/Projects/UserDetailProjects';
import { UserDocument } from './UserDetail.graphql';
+const useUserDetailsFilters = makeQueryHandler({
+ tab: withDefault(EnumParam(['profile', 'projects']), '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 +75,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 && }
+
+
+
+
+
+
>
)}
);
};
-
-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,
- }}
- />
- );