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, - }} - /> - );