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