feat: add RecordRelationFieldCardSection (#3176)

Closes #3123

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs
2024-01-05 07:02:02 -03:00
committed by GitHub
parent 80c1c9aacc
commit db46dd4497
17 changed files with 296 additions and 606 deletions

View File

@ -1,188 +0,0 @@
import { useState } from 'react';
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { flip, offset, useFloating } from '@floating-ui/react';
import { v4 } from 'uuid';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
import { RelationPicker } from '@/object-record/relation-picker/components/RelationPicker';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { IconPlus } from '@/ui/display/icon';
import { DoubleTextInput } from '@/ui/field/input/components/DoubleTextInput';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
position: static;
`;
const StyledInputContainer = styled.div`
background-color: transparent;
box-shadow: ${({ theme }) => theme.boxShadow.strong};
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
width: ${({ theme }) => theme.spacing(62.5)};
& input,
div {
background-color: ${({ theme }) => theme.background.primary};
width: 100%;
}
div {
border-radius: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
}
input {
display: flex;
flex-grow: 1;
padding: ${({ theme }) => theme.spacing(2)};
}
`;
export const AddPersonToCompany = ({
companyId,
peopleIds,
}: {
companyId: string;
peopleIds?: string[];
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isCreationDropdownOpen, setIsCreationDropdownOpen] = useState(false);
const { refs, floatingStyles } = useFloating({
open: isDropdownOpen,
placement: 'right-start',
middleware: [flip(), offset({ mainAxis: 30, crossAxis: 0 })],
});
const handleEscape = () => {
if (isCreationDropdownOpen) setIsCreationDropdownOpen(false);
if (isDropdownOpen) setIsDropdownOpen(false);
};
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const {
findManyRecordsQuery,
updateOneRecordMutation,
createOneRecordMutation,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const [updatePerson] = useMutation(updateOneRecordMutation);
const [createPerson] = useMutation(createOneRecordMutation);
const handlePersonSelected =
(companyId: string) => async (newPerson: EntityForSelect | null) => {
if (!newPerson) return;
await updatePerson({
variables: {
idToUpdate: newPerson.id,
input: {
companyId: companyId,
},
},
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
});
handleClosePicker();
};
const handleClosePicker = () => {
if (isDropdownOpen) {
setIsDropdownOpen(false);
goBackToPreviousHotkeyScope();
}
};
const handleOpenPicker = () => {
if (!isDropdownOpen) {
setIsDropdownOpen(true);
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}
};
const handleCreatePerson = async ({
firstValue,
secondValue,
}: FieldDoubleText) => {
if (!firstValue && !secondValue) return;
const newPersonId = v4();
await createPerson({
variables: {
input: {
companyId: companyId,
id: newPersonId,
name: {
firstName: firstValue,
lastName: secondValue,
},
},
},
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
});
setIsCreationDropdownOpen(false);
};
return (
<RecoilScope>
<StyledContainer ref={refs.setReference}>
<LightIconButton
Icon={IconPlus}
onClick={handleOpenPicker}
size="small"
accent="tertiary"
/>
{isDropdownOpen && (
<div ref={refs.setFloating} style={floatingStyles}>
{isCreationDropdownOpen ? (
<StyledInputContainer>
<DoubleTextInput
firstValue=""
secondValue=""
firstValuePlaceholder="First Name"
secondValuePlaceholder="Last Name"
onClickOutside={handleEscape}
onEnter={handleCreatePerson}
onEscape={handleEscape}
hotkeyScope={RelationPickerHotkeyScope.RelationPicker}
/>
</StyledInputContainer>
) : (
<RelationPicker
recordId={''}
onSubmit={handlePersonSelected(companyId)}
onCancel={handleClosePicker}
excludeRecordIds={peopleIds ?? []}
fieldDefinition={{
label: 'Person',
iconName: 'IconUser',
fieldMetadataId: '',
type: FieldMetadataType.Relation,
metadata: {
relationObjectMetadataNameSingular: 'person',
relationObjectMetadataNamePlural: 'people',
fieldName: 'person',
},
}}
/>
)}
</div>
)}
</StyledContainer>
</RecoilScope>
);
};

View File

@ -1,95 +0,0 @@
import { useQuery } from '@apollo/client';
import styled from '@emotion/styled';
import { isNonEmptyArray } from '@sniptt/guards';
import { Company } from '@/companies/types/Company';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { mapPaginatedRecordsToRecords } from '@/object-record/utils/mapPaginatedRecordsToRecords';
import { PeopleCard } from '@/people/components/PeopleCard';
import { AddPersonToCompany } from './AddPersonToCompany';
export type CompanyTeamProps = {
company: Pick<Company, 'id'>;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(6)};
`;
const StyledTitleContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
justify-content: space-between;
padding-bottom: ${({ theme }) => theme.spacing(0)};
padding-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledListContainer = styled.div`
align-items: flex-start;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: auto;
width: 100%;
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
`;
export const CompanyTeam = ({ company }: { company: any }) => {
const { findManyRecordsQuery } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const { data } = useQuery(findManyRecordsQuery, {
variables: {
filter: {
companyId: {
eq: company.id,
},
},
},
});
const people = mapPaginatedRecordsToRecords({
objectNamePlural: 'people',
pagedRecords: data ?? [],
});
const peopleIds = people.map((person) => person.id);
const hasPeople = isNonEmptyArray(peopleIds);
return (
<>
{hasPeople && (
<StyledContainer>
<StyledTitleContainer>
<StyledTitle>Team</StyledTitle>
<AddPersonToCompany companyId={company.id} peopleIds={peopleIds} />
</StyledTitleContainer>
<StyledListContainer>
{people.map((person: any) => (
<PeopleCard
key={person.id}
person={person}
hasBottomBorder={person.id !== people.length - 1}
/>
))}
</StyledListContainer>
</StyledContainer>
)}
</>
);
};

View File

@ -17,7 +17,12 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
objectMetadataItem: ObjectMetadataItem;
}): ColumnDefinition<FieldMetadata> => {
const relationObjectMetadataItem =
field.toRelationMetadata?.fromObjectMetadata;
field.toRelationMetadata?.fromObjectMetadata ||
field.fromRelationMetadata?.toObjectMetadata;
const relationFieldMetadataId =
field.toRelationMetadata?.fromFieldMetadataId ||
field.fromRelationMetadata?.toFieldMetadataId;
return {
position,
@ -29,6 +34,7 @@ export const formatFieldMetadataItemAsColumnDefinition = ({
fieldName: field.name,
placeHolder: field.label,
relationType: parseFieldRelationType(field),
relationFieldMetadataId,
relationObjectMetadataNameSingular:
relationObjectMetadataItem?.nameSingular ?? '',
relationObjectMetadataNamePlural:

View File

@ -1,7 +1,6 @@
import { useParams } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { CompanyTeam } from '@/companies/components/CompanyTeam';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
@ -15,6 +14,7 @@ import { entityFieldsFamilyState } from '@/object-record/field/states/entityFiel
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordRelationFieldCardSection } from '@/object-record/record-relation-card/components/RecordRelationFieldCardSection';
import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker';
import { isFieldMetadataItemAvailable } from '@/object-record/utils/isFieldMetadataItemAvailable';
import { IconBuildingSkyscraper } from '@/ui/display/icon';
@ -73,9 +73,7 @@ export const RecordShowPage = () => {
});
const [uploadImage] = useUploadImageMutation();
const { updateOneRecord } = useUpdateOneRecord({
objectNameSingular,
});
const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular });
const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => {
const updateEntity = ({ variables }: RecordUpdateHookParams) => {
@ -146,16 +144,26 @@ export const RecordShowPage = () => {
});
};
const fieldMetadataItemsToShow = [...objectMetadataItem.fields]
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
)
.filter(isFieldMetadataItemAvailable)
const availableFieldMetadataItems = objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
isFieldMetadataItemAvailable(fieldMetadataItem) &&
fieldMetadataItem.id !== labelIdentifierFieldMetadata?.id,
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldMetadataItems = availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.Relation,
);
const relationFieldMetadataItems = availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.Relation,
);
return (
<PageContainer>
<PageTitle title={pageName} />
@ -189,7 +197,7 @@ export const RecordShowPage = () => {
<RecoilScope CustomRecoilScopeContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer>
{!loading && record ? (
{!loading && !!record && (
<>
<ShowPageSummaryCard
id={record.id}
@ -232,7 +240,7 @@ export const RecordShowPage = () => {
}
/>
<PropertyBox extraPadding={true}>
{fieldMetadataItemsToShow.map(
{inlineFieldMetadataItems.map(
(fieldMetadataItem, index) => (
<FieldContext.Provider
key={record.id + fieldMetadataItem.id}
@ -255,16 +263,29 @@ export const RecordShowPage = () => {
),
)}
</PropertyBox>
{objectNameSingular === 'company' ? (
<>
<CompanyTeam company={record} />
</>
) : (
<></>
{relationFieldMetadataItems.map(
(fieldMetadataItem, index) => (
<FieldContext.Provider
key={record.id + fieldMetadataItem.id}
value={{
entityId: record.id,
recoilScopeId: record.id + fieldMetadataItem.id,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: index,
objectMetadataItem,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordRelationFieldCardSection />
</FieldContext.Provider>
),
)}
</>
) : (
<></>
)}
</ShowPageLeftContainer>
<ShowPageRightContainer

View File

@ -16,10 +16,7 @@ export const useRelationField = () => {
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<any | null>(
entityFieldsFamilySelector({
entityId: entityId,
fieldName: fieldName,
}),
entityFieldsFamilySelector({ entityId, fieldName }),
);
const fieldInitialValue = useFieldInitialValue();

View File

@ -75,12 +75,13 @@ export type FieldDefinitionRelationType =
| 'TO_ONE_OBJECT';
export type FieldRelationMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
useEditButton?: boolean;
relationType?: FieldDefinitionRelationType;
relationObjectMetadataNameSingular: string;
objectMetadataNameSingular?: string;
relationFieldMetadataId: string;
relationObjectMetadataNamePlural: string;
relationObjectMetadataNameSingular: string;
relationType?: FieldDefinitionRelationType;
useEditButton?: boolean;
};
export type FieldSelectMetadata = {

View File

@ -11,19 +11,21 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
export const useFieldContext = ({
objectNameSingular,
fieldMetadataName,
objectRecordId,
fieldPosition,
clearable,
fieldMetadataName,
fieldPosition,
isLabelIdentifier = false,
objectNameSingular,
objectRecordId,
}: {
objectNameSingular: string;
objectRecordId: string;
clearable?: boolean;
fieldMetadataName: string;
fieldPosition: number;
clearable?: boolean;
isLabelIdentifier?: boolean;
objectNameSingular: string;
objectRecordId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItem({
const { basePathToShowPage, objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
@ -52,9 +54,12 @@ export const useFieldContext = ({
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
value={{
basePathToShowPage: isLabelIdentifier
? basePathToShowPage
: undefined,
entityId: objectRecordId,
recoilScopeId: objectRecordId + fieldMetadataItem.id,
isLabelIdentifier: false,
isLabelIdentifier,
fieldDefinition: formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: fieldPosition,

View File

@ -7,7 +7,7 @@ export const useFindOneRecord = <
ObjectType extends { id: string } & Record<string, any>,
>({
objectNameSingular,
objectRecordId,
objectRecordId = '',
onCompleted,
depth,
skip,
@ -18,9 +18,7 @@ export const useFindOneRecord = <
depth?: number;
}) => {
const { objectMetadataItem, findOneRecordQuery } = useObjectMetadataItem(
{
objectNameSingular,
},
{ objectNameSingular },
depth,
);
@ -29,20 +27,12 @@ export const useFindOneRecord = <
{ objectRecordId: string }
>(findOneRecordQuery, {
skip: !objectMetadataItem || !objectRecordId || skip,
variables: {
objectRecordId: objectRecordId ?? '',
},
onCompleted: (data) => {
if (onCompleted) {
onCompleted(data[objectNameSingular]);
}
},
variables: { objectRecordId },
onCompleted: (data) => onCompleted?.(data[objectNameSingular]),
});
const record = data ? data[objectNameSingular] : undefined;
return {
record,
record: data?.[objectNameSingular] || undefined,
loading,
error,
};

View File

@ -4,8 +4,8 @@ import { entityFieldsFamilyState } from '@/object-record/field/states/entityFiel
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
// TODO: refactor with scoped state later
export const useUpsertRecordTableItem = () => {
return useRecoilCallback(
export const useUpsertRecordFromState = () =>
useRecoilCallback(
({ set, snapshot }) =>
<T extends { id: string }>(entity: T) => {
const currentEntity = snapshot
@ -18,4 +18,3 @@ export const useUpsertRecordTableItem = () => {
},
[],
);
};

View File

@ -0,0 +1,52 @@
import { useContext } from 'react';
import styled from '@emotion/styled';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldDisplay } from '@/object-record/field/components/FieldDisplay';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata';
import { useFieldContext } from '@/object-record/hooks/useFieldContext';
import { CardContent } from '@/ui/layout/card/components/CardContent';
const StyledCardContent = styled(CardContent)`
align-items: center;
display: flex;
height: ${({ theme }) => theme.spacing(10)};
padding: ${({ theme }) => theme.spacing(0, 2, 0, 3)};
`;
type RecordRelationFieldCardContentProps = {
divider?: boolean;
relationRecordId: string;
};
export const RecordRelationFieldCardContent = ({
divider,
relationRecordId,
}: RecordRelationFieldCardContentProps) => {
const { fieldDefinition } = useContext(FieldContext);
const { relationObjectMetadataNameSingular } =
fieldDefinition.metadata as FieldRelationMetadata;
const { labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata } =
useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
});
const { FieldContextProvider } = useFieldContext({
fieldMetadataName: relationLabelIdentifierFieldMetadata?.name || '',
fieldPosition: 0,
isLabelIdentifier: true,
objectNameSingular: relationObjectMetadataNameSingular,
objectRecordId: relationRecordId,
});
if (!FieldContextProvider) return null;
return (
<StyledCardContent divider={divider}>
<FieldContextProvider>
<FieldDisplay />
</FieldContextProvider>
</StyledCardContent>
);
};

View File

@ -0,0 +1,109 @@
import { useContext, useEffect, useMemo } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { FieldContext } from '@/object-record/field/contexts/FieldContext';
import { entityFieldsFamilySelector } from '@/object-record/field/states/selectors/entityFieldsFamilySelector';
import { FieldRelationMetadata } from '@/object-record/field/types/FieldMetadata';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordFromState } from '@/object-record/hooks/useUpsertRecordFromState';
import { RecordRelationFieldCardContent } from '@/object-record/record-relation-card/components/RecordRelationFieldCardContent';
import { Card } from '@/ui/layout/card/components/Card';
import { Section } from '@/ui/layout/section/components/Section';
const StyledTitle = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(0, 1)};
`;
export const RecordRelationFieldCardSection = () => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const {
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationType,
} = fieldDefinition.metadata as FieldRelationMetadata;
const {
labelIdentifierFieldMetadata: relationLabelIdentifierFieldMetadata,
objectMetadataItem: relationObjectMetadataItem,
} = useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
});
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
const fieldValue = useRecoilValue<
({ id: string } & Record<string, any>) | null
>(
entityFieldsFamilySelector({
entityId,
fieldName: fieldDefinition.metadata.fieldName,
}),
);
const isToOneObject = relationType === 'TO_ONE_OBJECT';
const { record: recordFromFieldValue } = useFindOneRecord({
objectNameSingular: relationObjectMetadataNameSingular,
objectRecordId: fieldValue?.id,
skip: !relationLabelIdentifierFieldMetadata || !isToOneObject,
});
// ONE_TO_MANY records cannot be retrieved from the field value,
// as the record's field is an empty "Connection" object.
// TODO: maybe the backend could return an array of related records instead?
const { records } = useFindManyRecords({
objectNameSingular: relationObjectMetadataNameSingular,
limit: 5,
filter: {
// TODO: this won't work for MANY_TO_MANY relations.
[`${relationFieldMetadataItem?.name}Id`]: {
eq: entityId,
},
},
skip:
!relationLabelIdentifierFieldMetadata ||
!relationFieldMetadataItem?.name ||
isToOneObject,
});
const relationRecords = useMemo(
() => (recordFromFieldValue ? [recordFromFieldValue] : records),
[recordFromFieldValue, records],
);
const upsertRecordFromState = useUpsertRecordFromState();
useEffect(() => {
if (!relationRecords.length) return;
relationRecords.forEach((relationRecord) =>
upsertRecordFromState(relationRecord),
);
}, [relationRecords, upsertRecordFromState]);
if (!relationLabelIdentifierFieldMetadata) return null;
return (
<Section>
<StyledTitle>{fieldDefinition.label}</StyledTitle>
{!!relationRecords.length && (
<Card>
{relationRecords.map((relationRecord, index) => (
<RecordRelationFieldCardContent
key={`${relationRecord.id}${relationLabelIdentifierFieldMetadata?.id}`}
divider={index < relationRecords.length - 1}
relationRecordId={relationRecord.id}
/>
))}
</Card>
)}
</Section>
);
};

View File

@ -12,6 +12,7 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV
import { FieldMetadata } from '../../field/types/FieldMetadata';
import { onEntityCountChangeScopedState } from '../states/onEntityCountChangeScopedState';
import { useUpsertRecordFromState } from '../../hooks/useUpsertRecordFromState';
import { ColumnDefinition } from '../types/ColumnDefinition';
import { TableHotkeyScope } from '../types/TableHotkeyScope';
@ -23,7 +24,6 @@ import { useSelectAllRows } from './internal/useSelectAllRows';
import { useSetRecordTableData } from './internal/useSetRecordTableData';
import { useSetRowSelectedState } from './internal/useSetRowSelectedState';
import { useSetSoftFocusPosition } from './internal/useSetSoftFocusPosition';
import { useUpsertRecordTableItem } from './internal/useUpsertRecordTableItem';
type useRecordTableProps = {
recordTableScopeId?: string;
@ -131,7 +131,7 @@ export const useRecordTable = (props?: useRecordTableProps) => {
const resetTableRowSelection = useResetTableRowSelection(scopeId);
const upsertRecordTableItem = useUpsertRecordTableItem();
const upsertRecordTableItem = useUpsertRecordFromState();
const setSoftFocusPosition = useSetSoftFocusPosition(scopeId);

View File

@ -1,13 +1,17 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType';
import { RelationMetadataType } from '~/generated-metadata/graphql';
export const isFieldMetadataItemAvailable = (
fieldMetadataItem: FieldMetadataItem,
) =>
fieldMetadataItem.type !== 'UUID' &&
// TODO: Many to many relations are not supported yet.
!(
fieldMetadataItem.type === 'RELATION' &&
parseFieldRelationType(fieldMetadataItem) !== 'TO_ONE_OBJECT'
(
fieldMetadataItem.fromRelationMetadata ??
fieldMetadataItem.toRelationMetadata
)?.relationType === RelationMetadataType.ManyToMany
) &&
!fieldMetadataItem.isSystem &&
!!fieldMetadataItem.isActive;

View File

@ -1,195 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { Person } from '@/people/types/Person';
import { IconDotsVertical, IconLinkOff, IconTrash } from '@/ui/display/icon';
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { Avatar } from '@/users/components/Avatar';
export type PeopleCardProps = {
person: Pick<Person, 'id' | 'avatarUrl' | 'name' | 'jobTitle'>;
hasBottomBorder?: boolean;
};
const StyledCard = styled.div<{
isHovered: boolean;
hasBottomBorder?: boolean;
}>`
align-items: center;
align-self: stretch;
background: ${({ theme, isHovered }) =>
isHovered ? theme.background.tertiary : 'auto'};
border-bottom: 1px solid
${({ theme, hasBottomBorder }) =>
hasBottomBorder ? theme.border.color.light : 'transparent'};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(8)};
padding: ${({ theme }) => theme.spacing(3)};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
cursor: pointer;
}
`;
const StyledCardInfo = styled.div`
align-items: flex-start;
display: flex;
flex: 1 0 0;
flex-direction: column;
`;
const StyledTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: ${({ theme }) => theme.text.lineHeight.lg};
`;
const StyledJobTitle = styled.div`
border-radius: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.font.color.tertiary};
padding-bottom: ${({ theme }) => theme.spacing(0.5)};
padding-left: ${({ theme }) => theme.spacing(0)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(0.5)};
&:hover {
background: ${({ theme }) => theme.background.tertiary};
}
`;
export const PeopleCard = ({
person,
hasBottomBorder = true,
}: PeopleCardProps) => {
const navigate = useNavigate();
const [isHovered, setIsHovered] = useState(false);
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
const { refs, floatingStyles } = useFloating({
strategy: 'absolute',
middleware: [offset(10), flip()],
whileElementsMounted: autoUpdate,
placement: 'right-start',
});
useListenClickOutside({
refs: [refs.floating],
callback: () => {
setIsOptionsOpen(false);
if (isOptionsOpen) {
setIsHovered(false);
}
},
});
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
if (!isOptionsOpen) {
setIsHovered(false);
}
};
const handleToggleOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setIsOptionsOpen(!isOptionsOpen);
};
const {
findManyRecordsQuery,
updateOneRecordMutation,
deleteOneRecordMutation,
} = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.Person,
});
const [updatePerson] = useMutation(updateOneRecordMutation);
const [deletePerson] = useMutation(deleteOneRecordMutation);
const handleDetachPerson = async () => {
await updatePerson({
variables: {
idToUpdate: person.id,
input: {
companyId: null,
},
},
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
});
};
const handleDeletePerson = () => {
deletePerson({
variables: {
idToDelete: person.id,
},
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
});
};
return (
<StyledCard
isHovered={isHovered}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={() => navigate(`/object/person/${person.id}`)}
hasBottomBorder={hasBottomBorder}
>
<Avatar
size="lg"
type="rounded"
placeholder={person.name.firstName + ' ' + person.name.lastName}
avatarUrl={person.avatarUrl}
/>
<StyledCardInfo>
<StyledTitle>
{person.name.firstName + ' ' + person.name.lastName}
</StyledTitle>
{person.jobTitle && <StyledJobTitle>{person.jobTitle}</StyledJobTitle>}
</StyledCardInfo>
{isHovered && (
<div ref={refs.setReference}>
<FloatingIconButton
onClick={handleToggleOptions}
size="small"
Icon={IconDotsVertical}
/>
{isOptionsOpen && (
<DropdownMenu
data-select-disable
ref={refs.setFloating}
style={floatingStyles}
>
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleDetachPerson}
LeftIcon={IconLinkOff}
text="Detach relation"
/>
<MenuItem
onClick={handleDeletePerson}
LeftIcon={IconTrash}
text="Delete person"
accent="danger"
/>
</DropdownMenuItemsContainer>
</DropdownMenu>
)}
</div>
)}
</StyledCard>
);
};

View File

@ -1,4 +1,4 @@
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -7,28 +7,23 @@ import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledOuterContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border-bottom-left-radius: 8px;
border-right: ${({ theme }) => {
const isMobile = useIsMobile();
return !isMobile ? `1px solid ${theme.border.color.medium}` : 'none';
}};
border-right: ${({ theme }) =>
useIsMobile() ? 'none' : `1px solid ${theme.border.color.medium}`};
border-top-left-radius: 8px;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
z-index: 10;
`;
const StyledInnerContainer = styled.div`
display: flex;
flex-direction: column;
padding: 0px ${({ theme }) => theme.spacing(2)} 0px
${({ theme }) => theme.spacing(3)};
width: ${({ theme }) => {
const isMobile = useIsMobile();
return isMobile ? `calc(100% - ${theme.spacing(5)})` : '320px';
}};
gap: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(2)};
width: ${({ theme }) =>
useIsMobile() ? `calc(100% - ${theme.spacing(5)})` : '320px'};
`;
const StyledIntermediateContainer = styled.div`
@ -38,7 +33,7 @@ const StyledIntermediateContainer = styled.div`
`;
export type ShowPageLeftContainerProps = {
children: ReactElement;
children: ReactNode;
};
export const ShowPageLeftContainer = ({

View File

@ -25,8 +25,7 @@ const StyledShowPageSummaryCard = styled.div`
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(3)}
${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledInfoContainer = styled.div`

View File

@ -9,7 +9,6 @@ import {
import { WorkspaceBuildSchemaOptions } from 'src/workspace/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface';
import { ObjectMetadataInterface } from 'src/metadata/field-metadata/interfaces/object-metadata.interface';
import { FieldMetadataType } from 'src/metadata/field-metadata/field-metadata.entity';
import { TypeDefinitionsStorage } from 'src/workspace/workspace-schema-builder/storages/type-definitions.storage';
import { objectContainsRelationField } from 'src/workspace/workspace-schema-builder/utils/object-contains-relation-field';
import { getResolverArgs } from 'src/workspace/workspace-schema-builder/utils/get-resolver-args.util';
@ -114,61 +113,52 @@ export class ExtendObjectTypeDefinitionFactory {
continue;
}
switch (fieldMetadata.type) {
case FieldMetadataType.RELATION: {
const relationMetadata =
fieldMetadata.fromRelationMetadata ??
fieldMetadata.toRelationMetadata;
const relationMetadata =
fieldMetadata.fromRelationMetadata ?? fieldMetadata.toRelationMetadata;
if (!relationMetadata) {
this.logger.error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
{
fieldMetadata,
},
);
if (!relationMetadata) {
this.logger.error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
{ fieldMetadata },
);
throw new Error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
);
}
const relationDirection = deduceRelationDirection(
fieldMetadata.objectMetadataId,
relationMetadata,
);
const relationType = this.relationTypeFactory.create(
fieldMetadata,
relationMetadata,
relationDirection,
);
let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined;
// Args are only needed when relation is of kind `oneToMany` and the relation direction is `from`
if (
relationMetadata.relationType ===
RelationMetadataType.ONE_TO_MANY &&
relationDirection === RelationDirection.FROM
) {
const args = getResolverArgs('findMany');
argsType = this.argsFactory.create(
{
args,
objectMetadataId: relationMetadata.toObjectMetadataId,
},
options,
);
}
fields[fieldMetadata.name] = {
type: relationType,
args: argsType,
description: fieldMetadata.description,
};
break;
}
throw new Error(
`Could not find a relation metadata for ${fieldMetadata.id}`,
);
}
const relationDirection = deduceRelationDirection(
fieldMetadata.objectMetadataId,
relationMetadata,
);
const relationType = this.relationTypeFactory.create(
fieldMetadata,
relationMetadata,
relationDirection,
);
let argsType: GraphQLFieldConfigArgumentMap | undefined = undefined;
// Args are only needed when relation is of kind `oneToMany` and the relation direction is `from`
if (
relationMetadata.relationType === RelationMetadataType.ONE_TO_MANY &&
relationDirection === RelationDirection.FROM
) {
const args = getResolverArgs('findMany');
argsType = this.argsFactory.create(
{
args,
objectMetadataId: relationMetadata.toObjectMetadataId,
},
options,
);
}
fields[fieldMetadata.name] = {
type: relationType,
args: argsType,
description: fieldMetadata.description,
};
}
return fields;