feat: Revamp navigation bar (#6031)

closes: #4428

Testing for fetchMoreRecords is pending, along with component tests

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Aditya Pimpalkar
2024-07-16 13:38:17 +01:00
committed by GitHub
parent a8dfff3a6d
commit 4a67cfa1c3
66 changed files with 1056 additions and 365 deletions

View File

@ -0,0 +1,20 @@
import { overlayScrollbarsState } from '@/ui/utilities/scroll/states/overlayScrollbarsState';
import { useRecoilCallback } from 'recoil';
export const useScrollToPosition = () => {
const scrollToPosition = useRecoilCallback(
({ snapshot }) =>
(scrollPositionInPx: number) => {
const overlayScrollbars = snapshot
.getLoadable(overlayScrollbarsState)
.getValue();
const scrollWrapper = overlayScrollbars?.elements().viewport;
scrollWrapper?.scrollTo({ top: scrollPositionInPx });
},
[],
);
return { scrollToPosition };
};

View File

@ -1,7 +1,7 @@
import { useContext } from 'react';
import { css, useTheme } from '@emotion/react'; import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Avatar, AvatarGroup, IconArrowRight, IconLock } from 'twenty-ui'; import { Avatar, AvatarGroup, IconArrowRight, IconLock } from 'twenty-ui';
@ -14,8 +14,8 @@ import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEv
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { Card } from '@/ui/layout/card/components/Card'; import { Card } from '@/ui/layout/card/components/Card';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CalendarChannelVisibility } from '~/generated/graphql';
import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; import { TimelineCalendarEvent } from '~/generated-metadata/graphql';
import { CalendarChannelVisibility } from '~/generated/graphql';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
@ -169,7 +169,9 @@ export const CalendarEventRow = ({
? `${participant.firstName} ${participant.lastName}` ? `${participant.firstName} ${participant.lastName}`
: participant.displayName : participant.displayName
} }
entityId={participant.workspaceMemberId ?? participant.personId} placeholderColorSeed={
participant.workspaceMemberId ?? participant.personId
}
type="rounded" type="rounded"
/> />
))} ))}

View File

@ -62,7 +62,7 @@ export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => {
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)}
size="md" size="md"
entityId={author?.id} placeholderColorSeed={author?.id}
placeholder={authorName} placeholder={authorName}
/> />
<StyledName>{authorName}</StyledName> <StyledName>{authorName}</StyledName>

View File

@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useMemo, useRef } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { Avatar, IconNotes, IconSparkles } from 'twenty-ui'; import { Avatar, IconNotes, IconSparkles } from 'twenty-ui';
@ -377,7 +377,7 @@ export const CommandMenu = () => {
<Avatar <Avatar
type="rounded" type="rounded"
avatarUrl={null} avatarUrl={null}
entityId={person.id} placeholderColorSeed={person.id}
placeholder={ placeholder={
person.name.firstName + person.name.firstName +
' ' + ' ' +
@ -399,7 +399,7 @@ export const CommandMenu = () => {
to={`object/company/${company.id}`} to={`object/company/${company.id}`}
Icon={() => ( Icon={() => (
<Avatar <Avatar
entityId={company.id} placeholderColorSeed={company.id}
placeholder={company.name} placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName( avatarUrl={getLogoUrlFromDomainName(
company.domainName, company.domainName,

View File

@ -80,7 +80,7 @@ export const Favorites = () => {
label={labelIdentifier} label={labelIdentifier}
Icon={() => ( Icon={() => (
<StyledAvatar <StyledAvatar
entityId={recordId} placeholderColorSeed={recordId}
avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)}
type={avatarType} type={avatarType}
placeholder={labelIdentifier} placeholder={labelIdentifier}

View File

@ -1,11 +1,10 @@
import React, { useMemo } from 'react'; import React from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect'; import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope';
import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField';
import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader'; import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader';
export const ObjectMetadataItemsProvider = ({ export const ObjectMetadataItemsProvider = ({
@ -15,23 +14,15 @@ export const ObjectMetadataItemsProvider = ({
const shouldDisplayChildren = objectMetadataItems.length > 0; const shouldDisplayChildren = objectMetadataItems.length > 0;
const chipGeneratorPerObjectPerField = useMemo(() => {
return getRecordChipGeneratorPerObjectPerField(objectMetadataItems);
}, [objectMetadataItems]);
return ( return (
<> <>
<ObjectMetadataItemsLoadEffect /> <ObjectMetadataItemsLoadEffect />
{shouldDisplayChildren ? ( {shouldDisplayChildren ? (
<PreComputedChipGeneratorsContext.Provider <PreComputedChipGeneratorsProvider>
value={{
chipGeneratorPerObjectPerField,
}}
>
<RelationPickerScope relationPickerScopeId="relation-picker"> <RelationPickerScope relationPickerScopeId="relation-picker">
{children} {children}
</RelationPickerScope> </RelationPickerScope>
</PreComputedChipGeneratorsContext.Provider> </PreComputedChipGeneratorsProvider>
) : ( ) : (
<UserOrMetadataLoader /> <UserOrMetadataLoader />
)} )}

View File

@ -0,0 +1,30 @@
import React, { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators';
export const PreComputedChipGeneratorsProvider = ({
children,
}: React.PropsWithChildren) => {
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } =
useMemo(() => {
return getRecordChipGenerators(objectMetadataItems);
}, [objectMetadataItems]);
return (
<>
<PreComputedChipGeneratorsContext.Provider
value={{
chipGeneratorPerObjectPerField,
identifierChipGeneratorPerObject,
}}
>
{children}
</PreComputedChipGeneratorsContext.Provider>
</>
);
};

View File

@ -3,13 +3,19 @@ import { createContext } from 'react';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ChipGeneratorPerObjectPerField = Record< export type ChipGeneratorPerObjectNameSingularPerFieldName = Record<
string, string,
Record<string, (record: ObjectRecord) => RecordChipData> Record<string, (record: ObjectRecord) => RecordChipData>
>; >;
export type IdentifierChipGeneratorPerObject = Record<
string,
(record: ObjectRecord) => RecordChipData
>;
export type PreComputedChipGeneratorsContextProps = { export type PreComputedChipGeneratorsContextProps = {
chipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField; chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName;
identifierChipGeneratorPerObject: IdentifierChipGeneratorPerObject;
}; };
export const PreComputedChipGeneratorsContext = export const PreComputedChipGeneratorsContext =

View File

@ -8,7 +8,7 @@ export const getLabelIdentifierFieldValue = (
record: ObjectRecord, record: ObjectRecord,
labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined, labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined,
objectNameSingular: string, objectNameSingular: string,
) => { ): string => {
if ( if (
objectNameSingular === CoreObjectNameSingular.WorkspaceMember || objectNameSingular === CoreObjectNameSingular.WorkspaceMember ||
labelIdentifierFieldMetadataItem?.type === FieldMetadataType.FullName labelIdentifierFieldMetadataItem?.type === FieldMetadataType.FullName
@ -17,7 +17,7 @@ export const getLabelIdentifierFieldValue = (
} }
if (isDefined(labelIdentifierFieldMetadataItem?.name)) { if (isDefined(labelIdentifierFieldMetadataItem?.name)) {
return record[labelIdentifierFieldMetadataItem.name] as string | number; return String(record[labelIdentifierFieldMetadataItem.name]);
} }
return ''; return '';

View File

@ -4,7 +4,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getLinkToShowPage = ( export const getLinkToShowPage = (
objectNameSingular: string, objectNameSingular: string,
record: ObjectRecord, record: Pick<ObjectRecord, 'id'>,
) => { ) => {
const basePathToShowPage = getBasePathToShowPage({ const basePathToShowPage = getBasePathToShowPage({
objectNameSingular, objectNameSingular,

View File

@ -1,13 +1,17 @@
import { EntityChip, EntityChipVariant } from 'twenty-ui'; import { AvatarChip, AvatarChipVariant } from 'twenty-ui';
import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isNonEmptyString } from '@sniptt/guards';
import { MouseEvent } from 'react';
import { useNavigate } from 'react-router-dom';
export type RecordChipProps = { export type RecordChipProps = {
objectNameSingular: string; objectNameSingular: string;
record: ObjectRecord; record: ObjectRecord;
className?: string; className?: string;
variant?: EntityChipVariant; variant?: AvatarChipVariant;
}; };
export const RecordChip = ({ export const RecordChip = ({
@ -16,19 +20,29 @@ export const RecordChip = ({
className, className,
variant, variant,
}: RecordChipProps) => { }: RecordChipProps) => {
const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ const navigate = useNavigate();
const { recordChipData } = useRecordChipData({
objectNameSingular, objectNameSingular,
record,
}); });
const objectRecordIdentifier = mapToObjectRecordIdentifier(record); const handleAvatarChipClick = (event: MouseEvent) => {
const linkToShowPage = getLinkToShowPage(objectNameSingular, record);
if (isNonEmptyString(linkToShowPage)) {
event.stopPropagation();
navigate(linkToShowPage);
}
};
return ( return (
<EntityChip <AvatarChip
entityId={record.id} placeholderColorSeed={record.id}
name={objectRecordIdentifier.name} name={recordChipData.name}
avatarType={objectRecordIdentifier.avatarType} avatarType={recordChipData.avatarType}
avatarUrl={objectRecordIdentifier.avatarUrl ?? ''} avatarUrl={recordChipData.avatarUrl ?? ''}
linkToEntity={objectRecordIdentifier.linkToShowPage} onClick={handleAvatarChipClick}
className={className} className={className}
variant={variant} variant={variant}
/> />

View File

@ -1,8 +1,14 @@
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { QueryCursorDirection } from '@/object-record/utils/generateFindManyRecordsQuery';
export type RecordGqlOperationVariables = { export type RecordGqlOperationVariables = {
filter?: RecordGqlOperationFilter; filter?: RecordGqlOperationFilter;
orderBy?: RecordGqlOperationOrderBy; orderBy?: RecordGqlOperationOrderBy;
limit?: number; limit?: number;
cursorFilter?: {
cursor: string;
cursorDirection: QueryCursorDirection;
limit: number;
};
}; };

View File

@ -38,7 +38,7 @@ export const useFetchAllRecordIds = <T>({
const firstQueryResult = const firstQueryResult =
findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural]; findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural];
const totalCount = firstQueryResult?.totalCount ?? 1; const totalCount = firstQueryResult?.totalCount ?? 0;
const recordsCount = firstQueryResult?.edges.length ?? 0; const recordsCount = firstQueryResult?.edges.length ?? 0;

View File

@ -34,6 +34,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
fetchPolicy, fetchPolicy,
onError, onError,
onCompleted, onCompleted,
cursorFilter,
}: UseFindManyRecordsParams<T>) => { }: UseFindManyRecordsParams<T>) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
@ -42,6 +43,7 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
const { findManyRecordsQuery } = useFindManyRecordsQuery({ const { findManyRecordsQuery } = useFindManyRecordsQuery({
objectNameSingular, objectNameSingular,
recordGqlFields, recordGqlFields,
cursorDirection: cursorFilter?.cursorDirection,
}); });
const { handleFindManyRecordsError } = useHandleFindManyRecordsError({ const { handleFindManyRecordsError } = useHandleFindManyRecordsError({
@ -67,15 +69,16 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
skip: skip || !objectMetadataItem || !currentWorkspaceMember, skip: skip || !objectMetadataItem || !currentWorkspaceMember,
variables: { variables: {
filter, filter,
limit,
orderBy, orderBy,
lastCursor: cursorFilter?.cursor ?? undefined,
limit: cursorFilter?.limit ?? limit,
}, },
fetchPolicy: fetchPolicy, fetchPolicy: fetchPolicy,
onCompleted: handleFindManyRecordsCompleted, onCompleted: handleFindManyRecordsCompleted,
onError: handleFindManyRecordsError, onError: handleFindManyRecordsError,
}); });
const { fetchMoreRecords, totalCount, records, hasNextPage } = const { fetchMoreRecords, records, hasNextPage } =
useFetchMoreRecordsWithPagination<T>({ useFetchMoreRecordsWithPagination<T>({
objectNameSingular, objectNameSingular,
filter, filter,
@ -87,6 +90,9 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
objectMetadataItem, objectMetadataItem,
}); });
const pageInfo = data?.[objectMetadataItem.namePlural].pageInfo;
const totalCount = data?.[objectMetadataItem.namePlural].totalCount;
return { return {
objectMetadataItem, objectMetadataItem,
records, records,
@ -96,5 +102,6 @@ export const useFindManyRecords = <T extends ObjectRecord = ObjectRecord>({
fetchMoreRecords, fetchMoreRecords,
queryStateIdentifier: queryIdentifier, queryStateIdentifier: queryIdentifier,
hasNextPage, hasNextPage,
pageInfo,
}; };
}; };

View File

@ -3,16 +3,21 @@ import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; import {
generateFindManyRecordsQuery,
QueryCursorDirection,
} from '@/object-record/utils/generateFindManyRecordsQuery';
export const useFindManyRecordsQuery = ({ export const useFindManyRecordsQuery = ({
objectNameSingular, objectNameSingular,
recordGqlFields, recordGqlFields,
computeReferences, computeReferences,
cursorDirection = 'after',
}: { }: {
objectNameSingular: string; objectNameSingular: string;
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences?: boolean; computeReferences?: boolean;
cursorDirection?: QueryCursorDirection;
}) => { }) => {
const { objectMetadataItem } = useObjectMetadataItem({ const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular, objectNameSingular,
@ -25,6 +30,7 @@ export const useFindManyRecordsQuery = ({
objectMetadataItems, objectMetadataItems,
recordGqlFields, recordGqlFields,
computeReferences, computeReferences,
cursorDirection,
}); });
return { return {

View File

@ -0,0 +1,24 @@
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useContext } from 'react';
export const useRecordChipData = ({
objectNameSingular,
record,
}: {
objectNameSingular: string;
record: ObjectRecord;
}) => {
const { identifierChipGeneratorPerObject } = useContext(
PreComputedChipGeneratorsContext,
);
const generateRecordChipData =
identifierChipGeneratorPerObject[objectNameSingular] ??
generateDefaultRecordChipData;
const recordChipData = generateRecordChipData(record);
return { recordChipData };
};

View File

@ -1,4 +1,4 @@
import { EntityChip, IconComponent } from 'twenty-ui'; import { AvatarChip, IconComponent } from 'twenty-ui';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
@ -13,8 +13,8 @@ export const GenericEntityFilterChip = ({
filter, filter,
Icon, Icon,
}: GenericEntityFilterChipProps) => ( }: GenericEntityFilterChipProps) => (
<EntityChip <AvatarChip
entityId={filter.value} placeholderColorSeed={filter.value}
name={filter.displayValue} name={filter.displayValue}
avatarType="rounded" avatarType="rounded"
avatarUrl={getImageAbsoluteURIOrBase64(filter.displayAvatarUrl) || ''} avatarUrl={getImageAbsoluteURIOrBase64(filter.displayAvatarUrl) || ''}

View File

@ -1,10 +1,9 @@
import styled from '@emotion/styled';
import { ReactNode, useContext, useState } from 'react'; import { ReactNode, useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { EntityChipVariant, IconEye } from 'twenty-ui'; import { AvatarChipVariant, IconEye } from 'twenty-ui';
import { RecordChip } from '@/object-record/components/RecordChip';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext';
@ -14,6 +13,7 @@ import {
RecordUpdateHookParams, RecordUpdateHookParams,
} from '@/object-record/record-field/contexts/FieldContext'; } from '@/object-record/record-field/contexts/FieldContext';
import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon'; import { getFieldButtonIcon } from '@/object-record/record-field/utils/getFieldButtonIcon';
import { RecordIndexRecordChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
@ -222,10 +222,10 @@ export const RecordBoardCard = () => {
}} }}
> >
<StyledBoardCardHeader showCompactView={isCompactModeActive}> <StyledBoardCardHeader showCompactView={isCompactModeActive}>
<RecordChip <RecordIndexRecordChip
objectNameSingular={objectMetadataItem.nameSingular} objectNameSingular={objectMetadataItem.nameSingular}
record={record} record={record}
variant={EntityChipVariant.Transparent} variant={AvatarChipVariant.Transparent}
/> />
{isCompactModeActive && ( {isCompactModeActive && (
<StyledCompactIconContainer className="compact-icon-container"> <StyledCompactIconContainer className="compact-icon-container">

View File

@ -4,14 +4,13 @@ import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/dis
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay'; import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
import { isFieldChipDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldChipDisplay';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
import { isFieldChipDisplay } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay';

View File

@ -1,23 +1,21 @@
import { EntityChip } from 'twenty-ui'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay'; import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay';
import { RecordIndexRecordChip } from '@/object-record/record-index/components/RecordIndexRecordChip';
export const ChipFieldDisplay = () => { export const ChipFieldDisplay = () => {
const { recordValue, generateRecordChipData } = useChipFieldDisplay(); const { recordValue, objectNameSingular, isLabelIdentifier } =
useChipFieldDisplay();
if (!recordValue) { if (!recordValue) {
return null; return null;
} }
const recordChipData = generateRecordChipData(recordValue); return isLabelIdentifier ? (
<RecordIndexRecordChip
return ( objectNameSingular={objectNameSingular}
<EntityChip record={recordValue}
entityId={recordValue.id}
name={recordChipData.name as any}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
linkToEntity={recordChipData.linkToShowPage}
/> />
) : (
<RecordChip objectNameSingular={objectNameSingular} record={recordValue} />
); );
}; };

View File

@ -1,37 +1,27 @@
import { EntityChip } from 'twenty-ui'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
export const RelationFromManyFieldDisplay = () => { export const RelationFromManyFieldDisplay = () => {
const { fieldValue, fieldDefinition, generateRecordChipData } = const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay();
useRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus(); const { isFocused } = useFieldFocus();
if ( const relationObjectNameSingular =
!fieldValue || fieldDefinition?.metadata.relationObjectMetadataNameSingular;
!fieldDefinition?.metadata.relationObjectMetadataNameSingular
) { if (!fieldValue || !relationObjectNameSingular) {
return null; return null;
} }
const recordChipsData = fieldValue.map((fieldValueItem) =>
generateRecordChipData(fieldValueItem),
);
return ( return (
<ExpandableList isChipCountDisplayed={isFocused}> <ExpandableList isChipCountDisplayed={isFocused}>
{recordChipsData.map((record) => { {fieldValue.map((record) => {
return ( return (
<EntityChip <RecordChip
key={record.recordId} key={record.id}
entityId={record.recordId} objectNameSingular={relationObjectNameSingular}
name={record.name as any} record={record}
avatarType={record.avatarType}
avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl) || ''}
linkToEntity={record.linkToShowPage}
/> />
); );
})} })}

View File

@ -1,7 +1,5 @@
import { EntityChip } from 'twenty-ui'; import { RecordChip } from '@/object-record/components/RecordChip';
import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay'; import { useRelationToOneFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
export const RelationToOneFieldDisplay = () => { export const RelationToOneFieldDisplay = () => {
const { fieldValue, fieldDefinition, generateRecordChipData } = const { fieldValue, fieldDefinition, generateRecordChipData } =
@ -17,12 +15,10 @@ export const RelationToOneFieldDisplay = () => {
const recordChipData = generateRecordChipData(fieldValue); const recordChipData = generateRecordChipData(fieldValue);
return ( return (
<EntityChip <RecordChip
entityId={fieldValue.id} key={recordChipData.recordId}
name={recordChipData.name as any} objectNameSingular={recordChipData.objectNameSingular}
avatarType={recordChipData.avatarType} record={fieldValue}
avatarUrl={getImageAbsoluteURIOrBase64(recordChipData.avatarUrl) || ''}
linkToEntity={recordChipData.linkToShowPage}
/> />
); );
}; };

View File

@ -0,0 +1,11 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
export const isFieldChipDisplay = (
field: Pick<FieldMetadataItem, 'type'>,
isLabelIdentifier: boolean,
) =>
isLabelIdentifier &&
(isFieldText(field) || isFieldFullName(field) || isFieldNumber(field));

View File

@ -1,8 +1,7 @@
import { useContext } from 'react';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
@ -12,7 +11,8 @@ import { isDefined } from '~/utils/isDefined';
import { FieldContext } from '../../contexts/FieldContext'; import { FieldContext } from '../../contexts/FieldContext';
export const useChipFieldDisplay = () => { export const useChipFieldDisplay = () => {
const { entityId, fieldDefinition } = useContext(FieldContext); const { entityId, fieldDefinition, isLabelIdentifier } =
useContext(FieldContext);
const { chipGeneratorPerObjectPerField } = useContext( const { chipGeneratorPerObjectPerField } = useContext(
PreComputedChipGeneratorsContext, PreComputedChipGeneratorsContext,
@ -31,18 +31,13 @@ export const useChipFieldDisplay = () => {
const recordValue = useRecordValue(entityId); const recordValue = useRecordValue(entityId);
if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) { if (!isNonEmptyString(objectNameSingular)) {
throw new Error('Object metadata name singular is not a non-empty string'); throw new Error('Object metadata name singular is not a non-empty string');
} }
const generateRecordChipData =
chipGeneratorPerObjectPerField[
fieldDefinition.metadata.objectMetadataNameSingular
]?.[fieldDefinition.metadata.fieldName] ?? generateDefaultRecordChipData;
return { return {
objectNameSingular, objectNameSingular,
recordValue, recordValue,
generateRecordChipData, isLabelIdentifier,
}; };
}; };

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const lastShowPageRecordIdState = createState<string | null>({
key: 'lastShowPageRecordIdState',
defaultValue: null,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const recordPositionInternalState = createState<number | null>({
key: 'recordPositionInternalState',
defaultValue: null,
});

View File

@ -2,8 +2,9 @@ import { AvatarType } from 'twenty-ui';
export type RecordChipData = { export type RecordChipData = {
recordId: string; recordId: string;
name: string | number; name: string;
avatarType: AvatarType; avatarType: AvatarType;
avatarUrl: string; avatarUrl: string;
linkToShowPage: string; isLabelIdentifier: boolean;
objectNameSingular: string;
}; };

View File

@ -1,15 +1,24 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import {
useRecoilCallback,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from 'recoil';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId';
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer';
import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader'; import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader';
import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect'; import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect';
import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer'; import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer';
import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext';
import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
@ -17,15 +26,22 @@ import { recordIndexIsCompactModeActiveState } from '@/object-record/record-inde
import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { useFindRecordCursorFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useFindRecordCursorFromFindManyCacheRootQuery';
import { findView } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar'; import { ViewBar } from '@/views/components/ViewBar';
import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState';
import { View } from '@/views/types/View';
import { ViewField } from '@/views/types/ViewField'; import { ViewField } from '@/views/types/ViewField';
import { ViewType } from '@/views/types/ViewType'; import { ViewType } from '@/views/types/ViewType';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useNavigate } from 'react-router-dom';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -108,6 +124,63 @@ export const RecordIndexContainer = ({
[columnDefinitions, setTableColumns], [columnDefinitions, setTableColumns],
); );
const navigate = useNavigate();
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const currentViewId = useRecoilValue(
currentViewIdComponentState({
scopeId: recordIndexId,
}),
);
const view = findView({
objectMetadataItemId: objectMetadataItem?.id ?? '',
viewId: currentViewId ?? null,
views,
});
const filter = turnObjectDropdownFilterIntoQueryFilter(
mapViewFiltersToFilters(view?.viewFilters ?? [], filterDefinitions),
objectMetadataItem?.fields ?? [],
);
const orderBy = turnSortsIntoOrderBy(
objectMetadataItem,
mapViewSortsToSorts(view?.viewSorts ?? [], sortDefinitions),
);
const { findCursorInCache } = useFindRecordCursorFromFindManyCacheRootQuery({
fieldVariables: {
filter,
orderBy,
},
objectNamePlural: objectNamePlural,
});
const handleIndexIdentifierClick = (recordId: string) => {
const cursor = findCursorInCache(recordId);
// TODO: use URL builder
navigate(
`/object/${objectNameSingular}/${recordId}?view=${currentViewId}`,
{
state: {
cursor,
},
},
);
};
const handleIndexRecordsLoaded = useRecoilCallback(
({ set }) =>
() => {
// TODO: find a better way to reset this state ?
set(lastShowPageRecordIdState, null);
},
[],
);
return ( return (
<StyledContainer> <StyledContainer>
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
@ -153,41 +226,46 @@ export const RecordIndexContainer = ({
/> />
</StyledContainerWithPadding> </StyledContainerWithPadding>
</SpreadsheetImportProvider> </SpreadsheetImportProvider>
<RecordIndexEventContext.Provider
{recordIndexViewType === ViewType.Table && ( value={{
<> onIndexIdentifierClick: handleIndexIdentifierClick,
<RecordIndexTableContainer onIndexRecordsLoaded: handleIndexRecordsLoaded,
recordTableId={recordIndexId} }}
viewBarId={recordIndexId} >
objectNameSingular={objectNameSingular} {recordIndexViewType === ViewType.Table && (
createRecord={createRecord} <>
/> <RecordIndexTableContainer
<RecordIndexTableContainerEffect recordTableId={recordIndexId}
objectNameSingular={objectNameSingular} viewBarId={recordIndexId}
recordTableId={recordIndexId} objectNameSingular={objectNameSingular}
viewBarId={recordIndexId} createRecord={createRecord}
/> />
</> <RecordIndexTableContainerEffect
)} objectNameSingular={objectNameSingular}
recordTableId={recordIndexId}
{recordIndexViewType === ViewType.Kanban && ( viewBarId={recordIndexId}
<StyledContainerWithPadding> />
<RecordIndexBoardContainer </>
recordBoardId={recordIndexId} )}
viewBarId={recordIndexId} {recordIndexViewType === ViewType.Kanban && (
objectNameSingular={objectNameSingular} <StyledContainerWithPadding>
createRecord={createRecord} <RecordIndexBoardContainer
/> recordBoardId={recordIndexId}
<RecordIndexBoardDataLoader viewBarId={recordIndexId}
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId} createRecord={createRecord}
/> />
<RecordIndexBoardDataLoaderEffect <RecordIndexBoardDataLoader
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId} recordBoardId={recordIndexId}
/> />
</StyledContainerWithPadding> <RecordIndexBoardDataLoaderEffect
)} objectNameSingular={objectNameSingular}
recordBoardId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
</RecordIndexEventContext.Provider>
</RecordFieldValueSelectorContextProvider> </RecordFieldValueSelectorContextProvider>
</StyledContainer> </StyledContainer>
); );

View File

@ -0,0 +1,40 @@
import { AvatarChip, AvatarChipVariant } from 'twenty-ui';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useContext } from 'react';
export type RecordIndexRecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
variant?: AvatarChipVariant;
};
export const RecordIndexRecordChip = ({
objectNameSingular,
record,
variant,
}: RecordIndexRecordChipProps) => {
const { onIndexIdentifierClick } = useContext(RecordIndexEventContext);
const { recordChipData } = useRecordChipData({
objectNameSingular,
record,
});
const handleAvatarChipClick = () => {
onIndexIdentifierClick(record.id);
};
return (
<AvatarChip
placeholderColorSeed={record.id}
name={recordChipData.name}
avatarType={recordChipData.avatarType}
avatarUrl={recordChipData.avatarUrl ?? ''}
onClick={handleAvatarChipClick}
variant={variant}
/>
);
};

View File

@ -0,0 +1,9 @@
import { createEventContext } from '~/utils/createEventContext';
export type RecordIndexEventContextProps = {
onIndexIdentifierClick: (recordId: string) => void;
onIndexRecordsLoaded: () => void;
};
export const RecordIndexEventContext =
createEventContext<RecordIndexEventContextProps>();

View File

@ -0,0 +1,34 @@
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { useApolloClient } from '@apollo/client';
import { createApolloStoreFieldName } from '~/utils/createApolloStoreFieldName';
export const useFindRecordCursorFromFindManyCacheRootQuery = ({
objectNamePlural,
fieldVariables,
}: {
objectNamePlural: string;
fieldVariables: {
filter: any;
orderBy: any;
};
}) => {
const apollo = useApolloClient();
const testsFieldNameOnRootQuery = createApolloStoreFieldName({
fieldName: objectNamePlural,
fieldVariables: fieldVariables,
});
const findCursorInCache = (recordId: string) => {
const extractedCache = apollo.cache.extract() as any;
const edgesInCache =
extractedCache?.['ROOT_QUERY']?.[testsFieldNameOnRootQuery]?.edges ?? [];
return edgesInCache.find(
(edge: RecordGqlRefEdge) => edge.node?.__ref.split(':')[1] === recordId,
)?.cursor;
};
return { findCursorInCache };
};

View File

@ -0,0 +1,30 @@
import { RecordGqlRefEdge } from '@/object-record/cache/types/RecordGqlRefEdge';
import { useApolloClient } from '@apollo/client';
import { createApolloStoreFieldName } from '~/utils/createApolloStoreFieldName';
export const useRecordIdsFromFindManyCacheRootQuery = ({
objectNamePlural,
fieldVariables,
}: {
objectNamePlural: string;
fieldVariables: {
filter: any;
orderBy: any;
};
}) => {
const apollo = useApolloClient();
const testsFieldNameOnRootQuery = createApolloStoreFieldName({
fieldName: objectNamePlural,
fieldVariables: fieldVariables,
});
const extractedCache = apollo.cache.extract() as any;
const recordIdsInCache: string[] =
extractedCache?.['ROOT_QUERY']?.[testsFieldNameOnRootQuery]?.edges?.map(
(edge: RecordGqlRefEdge) => edge.node?.__ref.split(':')[1],
) ?? [];
return { recordIdsInCache };
};

View File

@ -0,0 +1,253 @@
/* eslint-disable @nx/workspace-no-navigate-prefer-link */
import { useMemo, useState } from 'react';
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemsAsFilterDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId';
import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter';
import { useRecordIdsFromFindManyCacheRootQuery } from '@/object-record/record-show/hooks/useRecordIdsFromFindManyCacheRootQuery';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { View } from '@/views/types/View';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { isNonEmptyString } from '@sniptt/guards';
import { capitalize } from '~/utils/string/capitalize';
export const findView = ({
viewId,
objectMetadataItemId,
views,
}: {
viewId: string | null;
objectMetadataItemId: string;
views: View[];
}) => {
if (!viewId) {
return views.find(
(view: any) =>
view.key === 'INDEX' && view?.objectMetadataId === objectMetadataItemId,
) as View;
} else {
return views.find(
(view: any) =>
view?.id === viewId && view?.objectMetadataId === objectMetadataItemId,
) as View;
}
};
export const useRecordShowPagePagination = (
propsObjectNameSingular: string,
propsObjectRecordId: string,
) => {
const {
objectNameSingular: paramObjectNameSingular,
objectRecordId: paramObjectRecordId,
} = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const viewIdQueryParam = searchParams.get('view');
const setLastShowPageRecordId = useSetRecoilState(lastShowPageRecordIdState);
const [isLoadedRecords, setIsLoadedRecords] = useState(false);
const objectNameSingular = propsObjectNameSingular || paramObjectNameSingular;
const objectRecordId = propsObjectRecordId || paramObjectRecordId;
if (!objectNameSingular || !objectRecordId) {
throw new Error('Object name or Record id is not defined');
}
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { records: views } = usePrefetchedData<View>(PrefetchKey.AllViews);
const view = useMemo(() => {
return findView({
objectMetadataItemId: objectMetadataItem?.id ?? '',
viewId: viewIdQueryParam,
views,
});
}, [viewIdQueryParam, objectMetadataItem, views]);
const activeFieldMetadataItems = useMemo(
() =>
objectMetadataItem
? objectMetadataItem.fields.filter(
({ isActive, isSystem }) => isActive && !isSystem,
)
: [],
[objectMetadataItem],
);
const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({
fields: activeFieldMetadataItems,
});
const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({
fields: activeFieldMetadataItems,
});
const filter = turnObjectDropdownFilterIntoQueryFilter(
mapViewFiltersToFilters(view?.viewFilters ?? [], filterDefinitions),
objectMetadataItem?.fields ?? [],
);
const orderBy = turnSortsIntoOrderBy(
objectMetadataItem,
mapViewSortsToSorts(view?.viewSorts ?? [], sortDefinitions),
);
const recordGqlFields = generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const { state } = useLocation();
const cursorFromIndexPage = state.cursor;
const { loading: loadingCurrentRecord, pageInfo: currentRecordsPageInfo } =
useFindManyRecords({
filter: {
id: { eq: objectRecordId },
},
orderBy,
skip: isLoadedRecords,
limit: 1,
objectNameSingular,
recordGqlFields,
});
const currentRecordCursor = currentRecordsPageInfo?.endCursor;
const cursor = cursorFromIndexPage ?? currentRecordCursor;
const {
loading: loadingRecordBefore,
records: recordsBefore,
pageInfo: pageInfoBefore,
totalCount: totalCountBefore,
} = useFindManyRecords({
filter,
orderBy,
skip: isLoadedRecords,
cursorFilter: isNonEmptyString(cursor)
? {
cursorDirection: 'before',
cursor: cursor,
limit: 1,
}
: undefined,
objectNameSingular,
recordGqlFields,
});
const {
loading: loadingRecordAfter,
records: recordsAfter,
pageInfo: pageInfoAfter,
totalCount: totalCountAfter,
} = useFindManyRecords({
filter,
orderBy,
skip: isLoadedRecords,
cursorFilter: cursor
? {
cursorDirection: 'after',
cursor: cursor,
limit: 1,
}
: undefined,
objectNameSingular,
recordGqlFields,
});
const totalCount = Math.max(totalCountBefore ?? 0, totalCountAfter ?? 0);
const loading =
loadingRecordAfter || loadingRecordBefore || loadingCurrentRecord;
const isThereARecordBefore = recordsBefore.length > 0;
const isThereARecordAfter = recordsAfter.length > 0;
const recordBefore = recordsBefore[0];
const recordAfter = recordsAfter[0];
const recordBeforeCursor = pageInfoBefore?.endCursor;
const recordAfterCursor = pageInfoAfter?.endCursor;
const navigateToPreviousRecord = () => {
navigate(
`/object/${objectNameSingular}/${recordBefore.id}${
viewIdQueryParam ? `?view=${viewIdQueryParam}` : ''
}`,
{
state: {
cursor: recordBeforeCursor,
},
},
);
};
const navigateToNextRecord = () => {
navigate(
`/object/${objectNameSingular}/${recordAfter.id}${
viewIdQueryParam ? `?view=${viewIdQueryParam}` : ''
}`,
{
state: {
cursor: recordAfterCursor,
},
},
);
};
const navigateToIndexView = () => {
const indexPath = `/objects/${objectMetadataItem.namePlural}${
viewIdQueryParam ? `?view=${viewIdQueryParam}` : ''
}`;
setLastShowPageRecordId(objectRecordId);
navigate(indexPath);
};
const { recordIdsInCache } = useRecordIdsFromFindManyCacheRootQuery({
objectNamePlural: objectMetadataItem.namePlural,
fieldVariables: {
filter,
orderBy,
},
});
const rankInView = recordIdsInCache.findIndex((id) => id === objectRecordId);
const rankFoundInFiew = rankInView > -1;
const objectLabel = capitalize(objectMetadataItem.namePlural);
const viewNameWithCount = rankFoundInFiew
? `${rankInView + 1} of ${totalCount} in ${objectLabel}`
: `${objectLabel} (${totalCount})`;
return {
viewName: viewNameWithCount,
hasPreviousRecord: isThereARecordBefore,
isLoadingPagination: loading,
hasNextRecord: isThereARecordAfter,
navigateToPreviousRecord,
navigateToNextRecord,
navigateToIndexView,
};
};

View File

@ -21,6 +21,7 @@ const StyledTable = styled.table`
`; `;
type RecordTableProps = { type RecordTableProps = {
viewBarId: string;
recordTableId: string; recordTableId: string;
objectNameSingular: string; objectNameSingular: string;
onColumnsChange: (columns: any) => void; onColumnsChange: (columns: any) => void;
@ -28,6 +29,7 @@ type RecordTableProps = {
}; };
export const RecordTable = ({ export const RecordTable = ({
viewBarId,
recordTableId, recordTableId,
objectNameSingular, objectNameSingular,
onColumnsChange, onColumnsChange,
@ -68,6 +70,7 @@ export const RecordTable = ({
<RecordTableContextProvider <RecordTableContextProvider
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
recordTableId={recordTableId} recordTableId={recordTableId}
viewBarId={viewBarId}
> >
<RecordTableBodyEffect /> <RecordTableBodyEffect />
{!isRecordTableInitialLoading && {!isRecordTableInitialLoading &&

View File

@ -18,10 +18,12 @@ import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocus
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
export const RecordTableContextProvider = ({ export const RecordTableContextProvider = ({
viewBarId,
recordTableId, recordTableId,
objectNameSingular, objectNameSingular,
children, children,
}: { }: {
viewBarId: string;
recordTableId: string; recordTableId: string;
objectNameSingular: string; objectNameSingular: string;
children: ReactNode; children: ReactNode;
@ -90,6 +92,7 @@ export const RecordTableContextProvider = ({
return ( return (
<RecordTableContext.Provider <RecordTableContext.Provider
value={{ value={{
viewBarId,
objectMetadataItem, objectMetadataItem,
onUpsertRecord: handleUpsertRecord, onUpsertRecord: handleUpsertRecord,
onOpenTableCell: handleOpenTableCell, onOpenTableCell: handleOpenTableCell,

View File

@ -33,7 +33,7 @@ export const RecordTableInternalEffect = ({
callback: () => { callback: () => {
leaveTableFocus(); leaveTableFocus();
}, },
mode: ClickOutsideMode.comparePixels, mode: ClickOutsideMode.compareHTMLRef,
}); });
useScopedHotkeys( useScopedHotkeys(

View File

@ -76,6 +76,7 @@ export const RecordTableWithWrappers = ({
<StyledTableContainer> <StyledTableContainer>
<StyledTableInternalContainer ref={tableBodyRef}> <StyledTableInternalContainer ref={tableBodyRef}>
<RecordTable <RecordTable
viewBarId={viewBarId}
recordTableId={recordTableId} recordTableId={recordTableId}
objectNameSingular={objectNameSingular} objectNameSingular={objectNameSingular}
onColumnsChange={handleColumnsChange} onColumnsChange={handleColumnsChange}

View File

@ -64,6 +64,7 @@ const meta: Meta = {
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
<RecordTableContext.Provider <RecordTableContext.Provider
value={{ value={{
viewBarId: mockPerformance.entityId,
objectMetadataItem: mockPerformance.objectMetadataItem as any, objectMetadataItem: mockPerformance.objectMetadataItem as any,
onUpsertRecord: () => {}, onUpsertRecord: () => {},
onOpenTableCell: () => {}, onOpenTableCell: () => {},

View File

@ -9,6 +9,7 @@ import { MoveFocusDirection } from '@/object-record/record-table/types/MoveFocus
import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition'; import { TableCellPosition } from '@/object-record/record-table/types/TableCellPosition';
export type RecordTableContextProps = { export type RecordTableContextProps = {
viewBarId: string;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
onUpsertRecord: ({ onUpsertRecord: ({
persistField, persistField,

View File

@ -1,7 +1,8 @@
import { useContext, useEffect } from 'react'; import { useContext, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { lastShowPageRecordIdState } from '@/object-record/record-field/states/lastShowPageRecordId';
import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
@ -11,11 +12,17 @@ import { isFetchingMoreRecordsFamilyState } from '@/object-record/states/isFetch
import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState'; import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState';
import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState'; import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { isNonEmptyString } from '@sniptt/guards';
import { useScrollRestoration } from '~/hooks/useScrollRestoration'; import { useScrollRestoration } from '~/hooks/useScrollRestoration';
import { useScrollToPosition } from '~/hooks/useScrollToPosition';
export const ROW_HEIGHT = 32;
export const RecordTableBodyEffect = () => { export const RecordTableBodyEffect = () => {
const { objectNameSingular } = useContext(RecordTableContext); const { objectNameSingular } = useContext(RecordTableContext);
const [hasInitializedScroll, setHasInitiazedScroll] = useState(false);
const { const {
fetchMoreRecords: fetchMoreObjects, fetchMoreRecords: fetchMoreObjects,
records, records,
@ -76,9 +83,44 @@ export const RecordTableBodyEffect = () => {
} }
}, [scrollLeft, setIsRecordTableScrolledLeft]); }, [scrollLeft, setIsRecordTableScrolledLeft]);
const rowHeight = 32; const rowHeight = ROW_HEIGHT;
const viewportHeight = records.length * rowHeight; const viewportHeight = records.length * rowHeight;
const [lastShowPageRecordId, setLastShowPageRecordId] = useRecoilState(
lastShowPageRecordIdState,
);
const { scrollToPosition } = useScrollToPosition();
useEffect(() => {
if (isNonEmptyString(lastShowPageRecordId) && !hasInitializedScroll) {
const isRecordAlreadyFetched = records.some(
(record) => record.id === lastShowPageRecordId,
);
if (isRecordAlreadyFetched) {
const recordPosition = records.findIndex(
(record) => record.id === lastShowPageRecordId,
);
const positionInPx = recordPosition * ROW_HEIGHT;
scrollToPosition(positionInPx);
setHasInitiazedScroll(true);
}
}
}, [
loading,
isFetchingMoreObjects,
lastShowPageRecordId,
fetchMoreObjects,
records,
scrollToPosition,
hasInitializedScroll,
setLastShowPageRecordId,
]);
useScrollRestoration(viewportHeight); useScrollRestoration(viewportHeight);
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,3 @@
import { useNavigate } from 'react-router-dom';
import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2'; import { useInitDraftValueV2 } from '@/object-record/record-field/hooks/useInitDraftValueV2';
@ -21,6 +20,8 @@ import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useC
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext';
import { useContext } from 'react';
import { TableHotkeyScope } from '../../types/TableHotkeyScope'; import { TableHotkeyScope } from '../../types/TableHotkeyScope';
export const DEFAULT_CELL_SCOPE: HotkeyScope = { export const DEFAULT_CELL_SCOPE: HotkeyScope = {
@ -40,13 +41,13 @@ export type OpenTableCellArgs = {
}; };
export const useOpenRecordTableCellV2 = (tableScopeId: string) => { export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
const { onIndexIdentifierClick } = useContext(RecordIndexEventContext);
const moveEditModeToTableCellPosition = const moveEditModeToTableCellPosition =
useMoveEditModeToTableCellPosition(tableScopeId); useMoveEditModeToTableCellPosition(tableScopeId);
const setHotkeyScope = useSetHotkeyScope(); const setHotkeyScope = useSetHotkeyScope();
const { setDragSelectionStartEnabled } = useDragSelect(); const { setDragSelectionStartEnabled } = useDragSelect();
const navigate = useNavigate();
const leaveTableFocus = useLeaveTableFocus(tableScopeId); const leaveTableFocus = useLeaveTableFocus(tableScopeId);
const { toggleClickOutsideListener } = useClickOutsideListener( const { toggleClickOutsideListener } = useClickOutsideListener(
SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID, SOFT_FOCUS_CLICK_OUTSIDE_LISTENER_ID,
@ -66,7 +67,6 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
initialValue, initialValue,
cellPosition, cellPosition,
isReadOnly, isReadOnly,
pathToShowPage,
objectNameSingular, objectNameSingular,
customCellHotkeyScope, customCellHotkeyScope,
fieldDefinition, fieldDefinition,
@ -94,7 +94,8 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
if (isFirstColumnCell && !isEmpty && !isActionButtonClick) { if (isFirstColumnCell && !isEmpty && !isActionButtonClick) {
leaveTableFocus(); leaveTableFocus();
navigate(pathToShowPage);
onIndexIdentifierClick(entityId);
return; return;
} }
@ -142,7 +143,7 @@ export const useOpenRecordTableCellV2 = (tableScopeId: string) => {
openRightDrawer, openRightDrawer,
setViewableRecordId, setViewableRecordId,
setViewableRecordNameSingular, setViewableRecordNameSingular,
navigate, onIndexIdentifierClick,
], ],
); );

View File

@ -1,10 +1,11 @@
import { ReactNode, useContext } from 'react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { Draggable } from '@hello-pangea/dnd'; import { Draggable } from '@hello-pangea/dnd';
import { ReactNode, useContext, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
import { RecordIndexEventContext } from '@/object-record/record-index/contexts/RecordIndexEventContext';
import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext';
import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext';
import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates';
@ -23,6 +24,7 @@ export const RecordTableRowWrapper = ({
children: ReactNode; children: ReactNode;
}) => { }) => {
const { objectMetadataItem } = useContext(RecordTableContext); const { objectMetadataItem } = useContext(RecordTableContext);
const { onIndexRecordsLoaded } = useContext(RecordIndexEventContext);
const theme = useTheme(); const theme = useTheme();
@ -38,6 +40,13 @@ export const RecordTableRowWrapper = ({
rootMargin: '1000px', rootMargin: '1000px',
}); });
// TODO: find a better way to emit this event
useEffect(() => {
if (inView) {
onIndexRecordsLoaded?.();
}
}, [inView, onIndexRecordsLoaded]);
return ( return (
<Draggable key={recordId} draggableId={recordId} index={rowIndex}> <Draggable key={recordId} draggableId={recordId} index={rowIndex}>
{(draggableProvided, draggableSnapshot) => ( {(draggableProvided, draggableSnapshot) => (

View File

@ -66,7 +66,7 @@ export const MultipleObjectRecordSelectItem = ({
avatar={ avatar={
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(recordIdentifier.avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(recordIdentifier.avatarUrl)}
entityId={objectRecordId} placeholderColorSeed={objectRecordId}
placeholder={recordIdentifier.name} placeholder={recordIdentifier.name}
size="md" size="md"
type={recordIdentifier.avatarType ?? 'rounded'} type={recordIdentifier.avatarType ?? 'rounded'}

View File

@ -41,7 +41,7 @@ export const SelectableMenuItemSelect = ({
avatar={ avatar={
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(entity.avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(entity.avatarUrl)}
entityId={entity.id} placeholderColorSeed={entity.id}
placeholder={entity.name} placeholder={entity.name}
size="md" size="md"
type={entity.avatarType ?? 'rounded'} type={entity.avatarType ?? 'rounded'}

View File

@ -70,7 +70,7 @@ export const MultipleRecordSelectDropdown = ({
avatar={ avatar={
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(record.avatarUrl)}
entityId={record.id} placeholderColorSeed={record.id}
placeholder={record.name} placeholder={record.name}
size="md" size="md"
type={record.avatarType ?? 'rounded'} type={record.avatarType ?? 'rounded'}

View File

@ -1,21 +1,24 @@
import gql from 'graphql-tag'; import gql from 'graphql-tag';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isAggregationEnabled } from '@/object-metadata/utils/isAggregationEnabled';
import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields'; import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
import { capitalize } from '~/utils/string/capitalize'; import { capitalize } from '~/utils/string/capitalize';
export type QueryCursorDirection = 'before' | 'after';
export const generateFindManyRecordsQuery = ({ export const generateFindManyRecordsQuery = ({
objectMetadataItem, objectMetadataItem,
objectMetadataItems, objectMetadataItems,
recordGqlFields, recordGqlFields,
computeReferences, computeReferences,
cursorDirection,
}: { }: {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[]; objectMetadataItems: ObjectMetadataItem[];
recordGqlFields?: RecordGqlOperationGqlRecordFields; recordGqlFields?: RecordGqlOperationGqlRecordFields;
computeReferences?: boolean; computeReferences?: boolean;
cursorDirection?: QueryCursorDirection;
}) => gql` }) => gql`
query FindMany${capitalize( query FindMany${capitalize(
objectMetadataItem.namePlural, objectMetadataItem.namePlural,
@ -24,9 +27,11 @@ query FindMany${capitalize(
)}FilterInput, $orderBy: [${capitalize( )}FilterInput, $orderBy: [${capitalize(
objectMetadataItem.nameSingular, objectMetadataItem.nameSingular,
)}OrderByInput], $lastCursor: String, $limit: Int) { )}OrderByInput], $lastCursor: String, $limit: Int) {
${ ${objectMetadataItem.namePlural}(filter: $filter, orderBy: $orderBy, ${
objectMetadataItem.namePlural cursorDirection === 'before'
}(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ ? 'last: $limit, before: $lastCursor'
: 'first: $limit, after: $lastCursor'
} ){
edges { edges {
node ${mapObjectMetadataToGraphQLQuery({ node ${mapObjectMetadataToGraphQLQuery({
objectMetadataItems, objectMetadataItems,
@ -37,11 +42,12 @@ query FindMany${capitalize(
cursor cursor
} }
pageInfo { pageInfo {
${isAggregationEnabled(objectMetadataItem) ? 'hasNextPage' : ''} hasNextPage
hasPreviousPage
startCursor startCursor
endCursor endCursor
} }
${isAggregationEnabled(objectMetadataItem) ? 'totalCount' : ''} totalCount
} }
} }
`; `;

View File

@ -5,7 +5,12 @@ export const getQueryIdentifier = ({
filter, filter,
orderBy, orderBy,
limit, limit,
cursorFilter,
}: RecordGqlOperationVariables & { }: RecordGqlOperationVariables & {
objectNameSingular: string; objectNameSingular: string;
}) => }) =>
objectNameSingular + JSON.stringify(filter) + JSON.stringify(orderBy) + limit; objectNameSingular +
JSON.stringify(filter) +
JSON.stringify(orderBy) +
limit +
(cursorFilter ? JSON.stringify(cursorFilter) : undefined);

View File

@ -1,116 +0,0 @@
import { ChipGeneratorPerObjectPerField } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const isFieldChipDisplay = (
field: Pick<FieldMetadataItem, 'type'>,
isLabelIdentifier: boolean,
) =>
isLabelIdentifier &&
(isFieldText(field) || isFieldFullName(field) || isFieldNumber(field));
export const getRecordChipGeneratorPerObjectPerField = (
objectMetadataItems: ObjectMetadataItem[],
) => {
const recordChipGeneratorPerObjectPerField: ChipGeneratorPerObjectPerField =
{};
for (const objectMetadataItem of objectMetadataItems) {
const generatorPerField = Object.fromEntries<
(record: ObjectRecord) => RecordChipData
>(
objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem,
}) ||
fieldMetadataItem.type === FieldMetadataType.Relation ||
isFieldChipDisplay(
fieldMetadataItem,
isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem,
}),
),
)
.map((fieldMetadataItem) => {
const objectNameSingularToFind = isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem: objectMetadataItem,
})
? objectMetadataItem.nameSingular
: isFieldRelation(fieldMetadataItem)
? fieldMetadataItem.relationDefinition?.targetObjectMetadata
.nameSingular ?? undefined
: undefined;
const objectMetadataItemToUse = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingularToFind,
);
if (
!isDefined(objectMetadataItemToUse) ||
!isDefined(objectNameSingularToFind)
) {
return ['', () => ({}) as any];
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
const imageIdentifierFieldMetadata =
objectMetadataItemToUse.fields.find(
(field) =>
field.id ===
objectMetadataItemToUse.imageIdentifierFieldMetadataId,
);
const avatarType = getAvatarType(objectNameSingularToFind);
return [
fieldMetadataItem.name,
(record: ObjectRecord) => ({
recordId: record.id,
name: getLabelIdentifierFieldValue(
record,
labelIdentifierFieldMetadataItem,
objectMetadataItemToUse.nameSingular,
),
avatarUrl: getAvatarUrl(
objectMetadataItemToUse.nameSingular,
record,
imageIdentifierFieldMetadata,
),
avatarType,
linkToShowPage: getLinkToShowPage(
objectMetadataItemToUse.nameSingular,
record,
),
}),
];
}),
);
recordChipGeneratorPerObjectPerField[objectMetadataItem.nameSingular] =
generatorPerField;
}
return recordChipGeneratorPerObjectPerField;
};

View File

@ -0,0 +1,120 @@
import {
ChipGeneratorPerObjectNameSingularPerFieldName,
IdentifierChipGeneratorPerObject,
} from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { getAvatarUrl } from '@/object-metadata/utils/getAvatarUrl';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldValue } from '@/object-metadata/utils/getLabelIdentifierFieldValue';
import { isLabelIdentifierField } from '@/object-metadata/utils/isLabelIdentifierField';
import { isFieldChipDisplay } from '@/object-record/record-field/meta-types/display/utils/isFieldChipDisplay';
import { RecordChipData } from '@/object-record/record-field/types/RecordChipData';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { isDefined } from '~/utils/isDefined';
export const getRecordChipGenerators = (
objectMetadataItems: ObjectMetadataItem[],
) => {
const chipGeneratorPerObjectPerField: ChipGeneratorPerObjectNameSingularPerFieldName =
{};
const identifierChipGeneratorPerObject: IdentifierChipGeneratorPerObject = {};
for (const objectMetadataItem of objectMetadataItems) {
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const generatorPerField = Object.fromEntries<
(record: ObjectRecord) => RecordChipData
>(
objectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
labelIdentifierFieldMetadataItem?.id === fieldMetadataItem.id ||
fieldMetadataItem.type === FieldMetadataType.Relation ||
isFieldChipDisplay(
fieldMetadataItem,
isLabelIdentifierField({
fieldMetadataItem: fieldMetadataItem,
objectMetadataItem,
}),
),
)
.map((fieldMetadataItem) => {
const isLabelIdentifier =
labelIdentifierFieldMetadataItem?.id === fieldMetadataItem.id;
const currentObjectNameSingular = objectMetadataItem.nameSingular;
const fieldRelationObjectNameSingular =
fieldMetadataItem.relationDefinition?.targetObjectMetadata
.nameSingular ?? undefined;
const objectNameSingularToFind = isLabelIdentifier
? currentObjectNameSingular
: fieldRelationObjectNameSingular;
const objectMetadataItemToUse = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingularToFind,
);
if (
!isDefined(objectMetadataItemToUse) ||
!isDefined(objectNameSingularToFind)
) {
return ['', () => ({}) as any];
}
const labelIdentifierFieldMetadataItemToUse =
getLabelIdentifierFieldMetadataItem(objectMetadataItemToUse);
const imageIdentifierFieldMetadataToUse =
objectMetadataItemToUse.fields.find(
(field) =>
field.id ===
objectMetadataItemToUse.imageIdentifierFieldMetadataId,
);
const avatarType = getAvatarType(objectNameSingularToFind);
return [
fieldMetadataItem.name,
(record: ObjectRecord) =>
({
recordId: record.id,
name: getLabelIdentifierFieldValue(
record,
labelIdentifierFieldMetadataItemToUse,
objectMetadataItemToUse.nameSingular,
),
avatarUrl: getAvatarUrl(
objectMetadataItemToUse.nameSingular,
record,
imageIdentifierFieldMetadataToUse,
),
avatarType,
isLabelIdentifier,
objectNameSingular: objectNameSingularToFind,
}) satisfies RecordChipData,
];
}),
);
chipGeneratorPerObjectPerField[objectMetadataItem.nameSingular] =
generatorPerField;
if (isDefined(labelIdentifierFieldMetadataItem)) {
identifierChipGeneratorPerObject[objectMetadataItem.nameSingular] =
chipGeneratorPerObjectPerField[objectMetadataItem.nameSingular]?.[
labelIdentifierFieldMetadataItem.name
];
}
}
return {
chipGeneratorPerObjectPerField,
identifierChipGeneratorPerObject,
};
};

View File

@ -4,14 +4,15 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { import {
IconChevronLeft, IconChevronDown,
IconChevronUp,
IconComponent, IconComponent,
IconX,
MOBILE_VIEWPORT, MOBILE_VIEWPORT,
OverflowingTextWithTooltip, OverflowingTextWithTooltip,
} from 'twenty-ui'; } from 'twenty-ui';
import { IconButton } from '@/ui/input/button/components/IconButton'; import { IconButton } from '@/ui/input/button/components/IconButton';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton'; import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton';
import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState'; import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -53,6 +54,7 @@ const StyledLeftContainer = styled.div`
const StyledTitleContainer = styled.div` const StyledTitleContainer = styled.div`
display: flex; display: flex;
font-size: ${({ theme }) => theme.font.size.md}; font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(1)}; margin-left: ${({ theme }) => theme.spacing(1)};
max-width: 50%; max-width: 50%;
`; `;
@ -61,6 +63,7 @@ const StyledTopBarIconStyledTitleContainer = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;
gap: ${({ theme }) => theme.spacing(1)};
flex-direction: row; flex-direction: row;
`; `;
@ -89,7 +92,13 @@ const StyledSkeletonLoader = () => {
type PageHeaderProps = ComponentProps<'div'> & { type PageHeaderProps = ComponentProps<'div'> & {
title: string; title: string;
hasBackButton?: boolean; hasClosePageButton?: boolean;
onClosePage?: () => void;
hasPaginationButtons?: boolean;
hasPreviousRecord?: boolean;
hasNextRecord?: boolean;
navigateToPreviousRecord?: () => void;
navigateToNextRecord?: () => void;
Icon: IconComponent; Icon: IconComponent;
children?: ReactNode; children?: ReactNode;
loading?: boolean; loading?: boolean;
@ -97,7 +106,13 @@ type PageHeaderProps = ComponentProps<'div'> & {
export const PageHeader = ({ export const PageHeader = ({
title, title,
hasBackButton, hasClosePageButton,
onClosePage,
hasPaginationButtons,
hasPreviousRecord,
hasNextRecord,
navigateToPreviousRecord,
navigateToNextRecord,
Icon, Icon,
children, children,
loading, loading,
@ -114,19 +129,36 @@ export const PageHeader = ({
<NavigationDrawerCollapseButton direction="right" /> <NavigationDrawerCollapseButton direction="right" />
</StyledTopBarButtonContainer> </StyledTopBarButtonContainer>
)} )}
{hasBackButton && ( {hasClosePageButton && (
<UndecoratedLink to={-1}> <IconButton
<IconButton Icon={IconX}
Icon={IconChevronLeft} size="small"
size="small" variant="tertiary"
variant="tertiary" onClick={() => onClosePage?.()}
/> />
</UndecoratedLink>
)} )}
{loading ? ( {loading ? (
<StyledSkeletonLoader /> <StyledSkeletonLoader />
) : ( ) : (
<StyledTopBarIconStyledTitleContainer> <StyledTopBarIconStyledTitleContainer>
{hasPaginationButtons && (
<>
<IconButton
Icon={IconChevronUp}
size="small"
variant="secondary"
disabled={!hasPreviousRecord}
onClick={() => navigateToPreviousRecord?.()}
/>
<IconButton
Icon={IconChevronDown}
size="small"
variant="secondary"
disabled={!hasNextRecord}
onClick={() => navigateToNextRecord?.()}
/>
</>
)}
{Icon && <Icon size={theme.icon.size.md} />} {Icon && <Icon size={theme.icon.size.md} />}
<StyledTitleContainer data-testid="top-bar-title"> <StyledTitleContainer data-testid="top-bar-title">
<OverflowingTextWithTooltip text={title} /> <OverflowingTextWithTooltip text={title} />

View File

@ -1,7 +1,7 @@
import { ChangeEvent, ReactNode, useRef } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { ChangeEvent, ReactNode, useRef } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { AppTooltip, Avatar, AvatarType } from 'twenty-ui'; import { AppTooltip, Avatar, AvatarType } from 'twenty-ui';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
@ -124,7 +124,7 @@ export const ShowPageSummaryCard = ({
avatarUrl={logoOrAvatar} avatarUrl={logoOrAvatar}
onClick={onUploadPicture ? handleAvatarClick : undefined} onClick={onUploadPicture ? handleAvatarClick : undefined}
size="xl" size="xl"
entityId={id} placeholderColorSeed={id}
placeholder={avatarPlaceholder} placeholder={avatarPlaceholder}
type={avatarType} type={avatarType}
/> />

View File

@ -1,4 +1,4 @@
import { EntityChip } from 'twenty-ui'; import { AvatarChip } from 'twenty-ui';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
@ -9,8 +9,8 @@ export type UserChipProps = {
}; };
export const UserChip = ({ id, name, avatarUrl }: UserChipProps) => ( export const UserChip = ({ id, name, avatarUrl }: UserChipProps) => (
<EntityChip <AvatarChip
entityId={id} placeholderColorSeed={id}
name={name} name={name}
avatarType="rounded" avatarType="rounded"
avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl) || ''} avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl) || ''}

View File

@ -40,7 +40,7 @@ export const WorkspaceMemberCard = ({
<StyledContainer> <StyledContainer>
<Avatar <Avatar
avatarUrl={getImageAbsoluteURIOrBase64(workspaceMember.avatarUrl)} avatarUrl={getImageAbsoluteURIOrBase64(workspaceMember.avatarUrl)}
entityId={workspaceMember.id} placeholderColorSeed={workspaceMember.id}
placeholder={workspaceMember.name.firstName || ''} placeholder={workspaceMember.name.firstName || ''}
type="squared" type="squared"
size="xl" size="xl"
@ -53,7 +53,6 @@ export const WorkspaceMemberCard = ({
/> />
<StyledEmailText>{workspaceMember.userEmail}</StyledEmailText> <StyledEmailText>{workspaceMember.userEmail}</StyledEmailText>
</StyledContent> </StyledContent>
{accessory} {accessory}
</StyledContainer> </StyledContainer>
); );

View File

@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext';
import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect';
import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { PageBody } from '@/ui/layout/page/PageBody'; import { PageBody } from '@/ui/layout/page/PageBody';
@ -35,16 +36,35 @@ export const RecordShowPage = () => {
parameters.objectRecordId ?? '', parameters.objectRecordId ?? '',
); );
const {
viewName,
hasPreviousRecord,
hasNextRecord,
navigateToPreviousRecord,
navigateToNextRecord,
navigateToIndexView,
isLoadingPagination,
} = useRecordShowPagePagination(
parameters.objectNameSingular ?? '',
parameters.objectRecordId ?? '',
);
return ( return (
<RecordFieldValueSelectorContextProvider> <RecordFieldValueSelectorContextProvider>
<RecordValueSetterEffect recordId={objectRecordId} /> <RecordValueSetterEffect recordId={objectRecordId} />
<PageContainer> <PageContainer>
<PageTitle title={pageTitle} /> <PageTitle title={pageTitle} />
<PageHeader <PageHeader
title={pageName ?? ''} title={viewName}
hasBackButton hasPaginationButtons
hasClosePageButton
onClosePage={navigateToIndexView}
hasPreviousRecord={hasPreviousRecord}
navigateToPreviousRecord={navigateToPreviousRecord}
hasNextRecord={hasNextRecord}
navigateToNextRecord={navigateToNextRecord}
Icon={headerIcon} Icon={headerIcon}
loading={loading} loading={loading || isLoadingPagination}
> >
<> <>
<PageFavoriteButton <PageFavoriteButton

View File

@ -8,7 +8,7 @@ import {
PageDecoratorArgs, PageDecoratorArgs,
} from '~/testing/decorators/PageDecorator'; } from '~/testing/decorators/PageDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks'; import { graphqlMocks } from '~/testing/graphqlMocks';
import { getPeopleMock } from '~/testing/mock-data/people'; import { getPeopleMock, peopleQueryResult } from '~/testing/mock-data/people';
import { mockedWorkspaceMemberData } from '~/testing/mock-data/users'; import { mockedWorkspaceMemberData } from '~/testing/mock-data/users';
import { RecordShowPage } from '../RecordShowPage'; import { RecordShowPage } from '../RecordShowPage';
@ -22,12 +22,17 @@ const meta: Meta<PageDecoratorArgs> = {
routePath: '/object/:objectNameSingular/:objectRecordId', routePath: '/object/:objectNameSingular/:objectRecordId',
routeParams: { routeParams: {
':objectNameSingular': 'person', ':objectNameSingular': 'person',
':objectRecordId': '1234', ':objectRecordId': peopleMock[0].id,
}, },
}, },
parameters: { parameters: {
msw: { msw: {
handlers: [ handlers: [
graphql.query('FindManyPeople', () => {
return HttpResponse.json({
data: peopleQueryResult,
});
}),
graphql.query('FindOnePerson', () => { graphql.query('FindOnePerson', () => {
return HttpResponse.json({ return HttpResponse.json({
data: { data: {
@ -64,8 +69,8 @@ const meta: Meta<PageDecoratorArgs> = {
edges: [], edges: [],
pageInfo: { pageInfo: {
hasNextPage: false, hasNextPage: false,
startCursor: '1234', startCursor: peopleMock[0].id,
endCursor: '1234', endCursor: peopleMock[0].id,
}, },
totalCount: 0, totalCount: 0,
}, },

View File

@ -1,21 +1,21 @@
import { useMemo } from 'react';
import { Decorator } from '@storybook/react'; import { Decorator } from '@storybook/react';
import { useMemo } from 'react';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext';
import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField'; import { getRecordChipGenerators } from '@/object-record/utils/getRecordChipGenerators';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
export const ChipGeneratorsDecorator: Decorator = (Story) => { export const ChipGeneratorsDecorator: Decorator = (Story) => {
const chipGeneratorPerObjectPerField = useMemo(() => { const { chipGeneratorPerObjectPerField, identifierChipGeneratorPerObject } =
return getRecordChipGeneratorPerObjectPerField( useMemo(() => {
generatedMockObjectMetadataItems, return getRecordChipGenerators(generatedMockObjectMetadataItems);
); }, []);
}, []);
return ( return (
<PreComputedChipGeneratorsContext.Provider <PreComputedChipGeneratorsContext.Provider
value={{ value={{
chipGeneratorPerObjectPerField, chipGeneratorPerObjectPerField,
identifierChipGeneratorPerObject,
}} }}
> >
<Story /> <Story />

View File

@ -1,13 +1,12 @@
import { useEffect, useMemo } from 'react';
import { Decorator } from '@storybook/react'; import { Decorator } from '@storybook/react';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect'; import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getRecordChipGeneratorPerObjectPerField } from '@/object-record/utils/getRecordChipGeneratorPerObjectPerField';
import { mockedUserData } from '~/testing/mock-data/users'; import { mockedUserData } from '~/testing/mock-data/users';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@ -23,20 +22,12 @@ export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
setCurrentUser(mockedUserData); setCurrentUser(mockedUserData);
}, [setCurrentUser, setCurrentWorkspaceMember]); }, [setCurrentUser, setCurrentWorkspaceMember]);
const chipGeneratorPerObjectPerField = useMemo(() => {
return getRecordChipGeneratorPerObjectPerField(objectMetadataItems);
}, [objectMetadataItems]);
return ( return (
<> <>
<ObjectMetadataItemsLoadEffect /> <ObjectMetadataItemsLoadEffect />
<PreComputedChipGeneratorsContext.Provider <PreComputedChipGeneratorsProvider>
value={{
chipGeneratorPerObjectPerField,
}}
>
{!!objectMetadataItems.length && <Story />} {!!objectMetadataItems.length && <Story />}
</PreComputedChipGeneratorsContext.Provider> </PreComputedChipGeneratorsProvider>
</> </>
); );
}; };

View File

@ -0,0 +1,9 @@
export const createApolloStoreFieldName = ({
fieldName,
fieldVariables,
}: {
fieldName: string;
fieldVariables: Record<string, any>;
}) => {
return `${fieldName}(${JSON.stringify(fieldVariables)})`;
};

View File

@ -0,0 +1,12 @@
import { Context, createContext } from 'react';
type ObjectOfFunctions = {
[key: string]: (...args: any[]) => void;
};
export type EventContext<T extends ObjectOfFunctions> =
T extends ObjectOfFunctions ? T : never;
export const createEventContext = <T extends ObjectOfFunctions>(): Context<
EventContext<T>
> => createContext<EventContext<T>>({} as EventContext<T>);

View File

@ -1,6 +1,6 @@
import { useContext } from 'react';
import { styled } from '@linaria/react'; import { styled } from '@linaria/react';
import { isNonEmptyString, isUndefined } from '@sniptt/guards'; import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import { useContext } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { invalidAvatarUrlsState } from '@ui/display/avatar/components/states/isInvalidAvatarUrlState'; import { invalidAvatarUrlsState } from '@ui/display/avatar/components/states/isInvalidAvatarUrlState';
@ -50,7 +50,7 @@ export type AvatarProps = {
className?: string; className?: string;
size?: AvatarSize; size?: AvatarSize;
placeholder: string | undefined; placeholder: string | undefined;
entityId?: string; placeholderColorSeed?: string;
type?: Nullable<AvatarType>; type?: Nullable<AvatarType>;
color?: string; color?: string;
backgroundColor?: string; backgroundColor?: string;
@ -62,7 +62,7 @@ export const Avatar = ({
avatarUrl, avatarUrl,
size = 'md', size = 'md',
placeholder, placeholder,
entityId = placeholder, placeholderColorSeed = placeholder,
onClick, onClick,
type = 'squared', type = 'squared',
color, color,
@ -85,9 +85,10 @@ export const Avatar = ({
} }
}; };
const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25); const fixedColor =
color ?? stringToHslColor(placeholderColorSeed ?? '', 75, 25);
const fixedBackgroundColor = const fixedBackgroundColor =
backgroundColor ?? stringToHslColor(entityId ?? '', 75, 85); backgroundColor ?? stringToHslColor(placeholderColorSeed ?? '', 75, 85);
const showBackgroundColor = showPlaceholder; const showBackgroundColor = showPlaceholder;

View File

@ -13,7 +13,7 @@ import { AvatarGroup, AvatarGroupProps } from '../AvatarGroup';
const makeAvatar = (userName: string, props: Partial<AvatarProps> = {}) => ( const makeAvatar = (userName: string, props: Partial<AvatarProps> = {}) => (
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
<Avatar placeholder={userName} entityId={userName} {...props} /> <Avatar placeholder={userName} placeholderColorSeed={userName} {...props} />
); );
const getAvatars = (commonProps: Partial<AvatarProps> = {}) => [ const getAvatars = (commonProps: Partial<AvatarProps> = {}) => [

View File

@ -1,56 +1,47 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
import { Avatar } from '@ui/display/avatar/components/Avatar'; import { Avatar } from '@ui/display/avatar/components/Avatar';
import { AvatarType } from '@ui/display/avatar/types/AvatarType'; import { AvatarType } from '@ui/display/avatar/types/AvatarType';
import { Chip, ChipVariant } from '@ui/display/chip/components/Chip'; import { Chip, ChipVariant } from '@ui/display/chip/components/Chip';
import { IconComponent } from '@ui/display/icon/types/IconComponent'; import { IconComponent } from '@ui/display/icon/types/IconComponent';
import { isDefined } from '@ui/utilities/isDefined';
import { Nullable } from '@ui/utilities/types/Nullable'; import { Nullable } from '@ui/utilities/types/Nullable';
import { MouseEvent } from 'react';
export type EntityChipProps = { export type AvatarChipProps = {
linkToEntity?: string;
entityId: string;
name: string; name: string;
avatarUrl?: string; avatarUrl?: string;
avatarType?: Nullable<AvatarType>; avatarType?: Nullable<AvatarType>;
variant?: EntityChipVariant; variant?: AvatarChipVariant;
LeftIcon?: IconComponent; LeftIcon?: IconComponent;
className?: string; className?: string;
placeholderColorSeed?: string;
onClick?: (event: MouseEvent) => void;
}; };
export enum EntityChipVariant { export enum AvatarChipVariant {
Regular = 'regular', Regular = 'regular',
Transparent = 'transparent', Transparent = 'transparent',
} }
export const EntityChip = ({ export const AvatarChip = ({
linkToEntity,
entityId,
name, name,
avatarUrl, avatarUrl,
avatarType = 'rounded', avatarType = 'rounded',
variant = EntityChipVariant.Regular, variant = AvatarChipVariant.Regular,
LeftIcon, LeftIcon,
className, className,
}: EntityChipProps) => { placeholderColorSeed,
const navigate = useNavigate(); onClick,
}: AvatarChipProps) => {
const theme = useTheme(); const theme = useTheme();
const handleLinkClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (isNonEmptyString(linkToEntity)) {
event.stopPropagation();
navigate(linkToEntity);
}
};
return ( return (
<Chip <Chip
label={name} label={name}
variant={ variant={
linkToEntity isDefined(onClick)
? variant === EntityChipVariant.Regular ? variant === AvatarChipVariant.Regular
? ChipVariant.Highlighted ? ChipVariant.Highlighted
: ChipVariant.Regular : ChipVariant.Regular
: ChipVariant.Transparent : ChipVariant.Transparent
@ -61,15 +52,15 @@ export const EntityChip = ({
) : ( ) : (
<Avatar <Avatar
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
entityId={entityId} placeholderColorSeed={placeholderColorSeed}
placeholder={name} placeholder={name}
size="sm" size="sm"
type={avatarType} type={avatarType}
/> />
) )
} }
clickable={!!linkToEntity} clickable={isDefined(onClick)}
onClick={handleLinkClick} onClick={onClick}
className={className} className={className}
/> />
); );

View File

@ -1,21 +1,19 @@
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { AvatarChip } from '@ui/display/chip/components/AvatarChip';
import { ComponentDecorator, RouterDecorator } from '@ui/testing'; import { ComponentDecorator, RouterDecorator } from '@ui/testing';
import { EntityChip } from '../EntityChip'; const meta: Meta<typeof AvatarChip> = {
title: 'UI/Display/Chip/AvatarChip',
const meta: Meta<typeof EntityChip> = { component: AvatarChip,
title: 'UI/Display/Chip/EntityChip',
component: EntityChip,
decorators: [RouterDecorator, ComponentDecorator], decorators: [RouterDecorator, ComponentDecorator],
args: { args: {
name: 'Entity name', name: 'Entity name',
linkToEntity: '/entity-link',
avatarType: 'squared', avatarType: 'squared',
}, },
}; };
export default meta; export default meta;
type Story = StoryObj<typeof EntityChip>; type Story = StoryObj<typeof AvatarChip>;
export const Default: Story = {}; export const Default: Story = {};

View File

@ -6,8 +6,8 @@ export * from './avatar/types/AvatarSize';
export * from './avatar/types/AvatarType'; export * from './avatar/types/AvatarType';
export * from './checkmark/components/AnimatedCheckmark'; export * from './checkmark/components/AnimatedCheckmark';
export * from './checkmark/components/Checkmark'; export * from './checkmark/components/Checkmark';
export * from './chip/components/AvatarChip';
export * from './chip/components/Chip'; export * from './chip/components/Chip';
export * from './chip/components/EntityChip';
export * from './color/components/ColorSample'; export * from './color/components/ColorSample';
export * from './icon/components/IconAddressBook'; export * from './icon/components/IconAddressBook';
export * from './icon/components/IconGmail'; export * from './icon/components/IconGmail';

View File

@ -1,3 +1,4 @@
export * from './color/utils/stringToHslColor'; export * from './color/utils/stringToHslColor';
export * from './isDefined';
export * from './state/utils/createState'; export * from './state/utils/createState';
export * from './types/Nullable'; export * from './types/Nullable';

View File

@ -0,0 +1,4 @@
import { isNull, isUndefined } from '@sniptt/guards';
export const isDefined = <T>(value: T | null | undefined): value is T =>
!isUndefined(value) && !isNull(value);