Show Data Skeleton Loading (#5328)

### Description

Show Data Skeleton loading

### Refs

#4460

### Demo

Figma:
https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty?type=design&node-id=25429-70096&mode=design&t=VRxtgYCKnJkl2zpt-0

https://jam.dev/c/178878cb-e600-4370-94d5-c8c12c8fe0d5

Fixes #4460

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>
This commit is contained in:
gitstart-twenty
2024-05-14 10:58:30 +01:00
committed by GitHub
parent de438b0171
commit 1bc9b780e5
9 changed files with 259 additions and 68 deletions

View File

@ -1,3 +1,5 @@
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
@ -27,15 +29,84 @@ const StyledMainContainer = styled.div`
justify-content: center;
`;
const StyledSkeletonContainer = styled.div`
align-items: center;
width: 100%;
padding: ${({ theme }) => theme.spacing(8)};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
flex-wrap: wrap;
align-content: flex-start;
`;
const StyledSkeletonSubSection = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledSkeletonColumn = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
justify-content: center;
`;
const StyledSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={80}
>
<Skeleton width={24} height={84} />
</SkeletonTheme>
);
};
const StyledTimelineSkeletonLoader = () => {
const theme = useTheme();
const skeletonItems = Array.from({ length: 3 }).map((_, index) => ({
id: `skeleton-item-${index}`,
}));
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
<Skeleton width={440} height={16} />
{skeletonItems.map(({ id }) => (
<StyledSkeletonSubSection key={id}>
<StyledSkeletonLoader />
<StyledSkeletonColumn>
<Skeleton width={400} height={24} />
<Skeleton width={400} height={24} />
</StyledSkeletonColumn>
</StyledSkeletonSubSection>
))}
</StyledSkeletonContainer>
</SkeletonTheme>
);
};
export const Timeline = ({
targetableObject,
loading,
}: {
targetableObject: ActivityTargetableObject;
loading?: boolean;
}) => {
const timelineActivitiesForGroup = useRecoilValue(
timelineActivitiesForGroupState,
);
if (loading === true) {
return <StyledTimelineSkeletonLoader />;
}
if (timelineActivitiesForGroup.length === 0) {
return (
<AnimatedPlaceholderEmptyContainer>

View File

@ -17,9 +17,13 @@ import { RecordInlineCellContainer } from './RecordInlineCellContainer';
type RecordInlineCellProps = {
readonly?: boolean;
loading?: boolean;
};
export const RecordInlineCell = ({ readonly }: RecordInlineCellProps) => {
export const RecordInlineCell = ({
readonly,
loading,
}: RecordInlineCellProps) => {
const { fieldDefinition, entityId } = useContext(FieldContext);
const buttonIcon = useGetButtonIcon();
@ -99,6 +103,7 @@ export const RecordInlineCell = ({ readonly }: RecordInlineCellProps) => {
isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly}
loading={loading}
/>
);
};

View File

@ -1,4 +1,5 @@
import React, { useContext, useEffect, useRef, useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Tooltip } from 'react-tooltip';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -82,6 +83,25 @@ const StyledTooltip = styled(Tooltip)`
padding: ${({ theme }) => theme.spacing(2)};
`;
const StyledSkeletonDiv = styled.div`
height: 24px;
`;
const StyledInlineCellSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonDiv>
<Skeleton width={154} height={16} />
</StyledSkeletonDiv>
</SkeletonTheme>
);
};
type RecordInlineCellContainerProps = {
readonly?: boolean;
IconLabel?: IconComponent;
@ -96,6 +116,7 @@ type RecordInlineCellContainerProps = {
isDisplayModeContentEmpty?: boolean;
isDisplayModeFixHeight?: boolean;
disableHoverEffect?: boolean;
loading?: boolean;
};
export const RecordInlineCellContainer = ({
@ -112,6 +133,7 @@ export const RecordInlineCellContainer = ({
editModeContentOnly,
isDisplayModeFixHeight,
disableHoverEffect,
loading = false,
}: RecordInlineCellContainerProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const reference = useRef<HTMLDivElement>(null);
@ -163,6 +185,43 @@ export const RecordInlineCellContainer = ({
}
}, [isHoveredForDisplayMode, displayModeContent, reference]);
const showContent = () => {
if (loading) {
return <StyledInlineCellSkeletonLoader />;
}
return !readonly && isInlineCellInEditMode ? (
<RecordInlineCellEditMode>{editModeContent}</RecordInlineCellEditMode>
) : editModeContentOnly ? (
<StyledClickableContainer readonly={readonly}>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{editModeContent}
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
) : (
<StyledClickableContainer
readonly={readonly}
onClick={handleDisplayModeClick}
>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{newDisplayModeContent}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
);
};
return (
<StyledInlineCellBaseContainer
onMouseEnter={handleContainerMouseEnter}
@ -194,37 +253,7 @@ export const RecordInlineCellContainer = ({
</StyledLabelAndIconContainer>
)}
<StyledValueContainer ref={reference}>
{!readonly && isInlineCellInEditMode ? (
<RecordInlineCellEditMode>{editModeContent}</RecordInlineCellEditMode>
) : editModeContentOnly ? (
<StyledClickableContainer readonly={readonly}>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{editModeContent}
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
) : (
<StyledClickableContainer
readonly={readonly}
onClick={handleDisplayModeClick}
>
<RecordInlineCellDisplayMode
disableHoverEffect={disableHoverEffect}
isDisplayModeContentEmpty={isDisplayModeContentEmpty}
isDisplayModeFixHeight={isDisplayModeFixHeight}
isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label}
>
{newDisplayModeContent}
</RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer>
)}
{showContent()}
</StyledValueContainer>
</StyledInlineCellBaseContainer>
);

View File

@ -36,11 +36,13 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type RecordShowContainerProps = {
objectNameSingular: string;
objectRecordId: string;
loading: boolean;
};
export const RecordShowContainer = ({
objectNameSingular,
objectRecordId,
loading,
}: RecordShowContainerProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
@ -130,13 +132,14 @@ export const RecordShowContainer = ({
<RecoilScope CustomRecoilScopeContext={ShowPageRecoilScopeContext}>
<ShowPageContainer>
<ShowPageLeftContainer>
{!recordLoading && isDefined(recordFromStore) && (
{isDefined(recordFromStore) && (
<>
<ShowPageSummaryCard
id={objectRecordId}
logoOrAvatar={recordIdentifier?.avatarUrl ?? ''}
avatarPlaceholder={recordIdentifier?.name ?? ''}
date={recordFromStore.createdAt ?? ''}
loading={loading || recordLoading}
title={
<FieldContext.Provider
value={{
@ -193,7 +196,10 @@ export const RecordShowContainer = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell readonly={isReadOnly} />
<RecordInlineCell
loading={loading || recordLoading}
readonly={isReadOnly}
/>
</FieldContext.Provider>
))}
</PropertyBox>
@ -217,7 +223,9 @@ export const RecordShowContainer = ({
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordDetailRelationSection />
<RecordDetailRelationSection
loading={loading || recordLoading}
/>
</FieldContext.Provider>
))}
</>
@ -233,6 +241,7 @@ export const RecordShowContainer = ({
tasks
notes
emails
loading={loading || recordLoading}
/>
) : (
<></>

View File

@ -1,4 +1,6 @@
import { useCallback, useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import qs from 'qs';
import { useRecoilValue } from 'recoil';
@ -27,11 +29,36 @@ import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
type RecordDetailRelationSectionProps = {
loading: boolean;
};
const StyledAddDropdown = styled(Dropdown)`
margin-left: auto;
`;
export const RecordDetailRelationSection = () => {
const StyledSkeletonDiv = styled.div`
height: 40px;
`;
const StyledRecordDetailRelationSectionSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonDiv>
<Skeleton width={129} height={16} />
</StyledSkeletonDiv>
</SkeletonTheme>
);
};
export const RecordDetailRelationSection = ({
loading,
}: RecordDetailRelationSectionProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext);
const {
fieldName,
@ -113,6 +140,20 @@ export const RecordDetailRelationSection = () => {
relationObjectMetadataItem.namePlural
}?${qs.stringify(filterQueryParams)}`;
const showContent = () => {
if (loading) {
return <StyledRecordDetailRelationSectionSkeletonLoader />;
}
return relationRecords.length ? (
<RecordDetailRelationRecordsList relationRecords={relationRecords} />
) : (
<RecordDetailRelationRecordsListEmptyState
relationObjectMetadataItem={relationObjectMetadataItem}
/>
);
};
return (
<RecordDetailSection>
<RecordDetailSectionHeader
@ -159,13 +200,7 @@ export const RecordDetailRelationSection = () => {
</DropdownScope>
}
/>
{relationRecords.length ? (
<RecordDetailRelationRecordsList relationRecords={relationRecords} />
) : (
<RecordDetailRelationRecordsListEmptyState
relationObjectMetadataItem={relationObjectMetadataItem}
/>
)}
{showContent()}
</RecordDetailSection>
);
};

View File

@ -53,6 +53,7 @@ type ShowPageRightContainerProps = {
tasks?: boolean;
notes?: boolean;
emails?: boolean;
loading?: boolean;
};
export const ShowPageRightContainer = ({
@ -61,6 +62,7 @@ export const ShowPageRightContainer = ({
tasks,
notes,
emails,
loading,
}: ShowPageRightContainerProps) => {
const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
const activeTabId = useRecoilValue(activeTabIdState);
@ -127,12 +129,16 @@ export const ShowPageRightContainer = ({
return (
<StyledShowPageRightContainer>
<StyledTabListContainer>
<TabList tabListId={TAB_LIST_COMPONENT_ID} tabs={TASK_TABS} />
<TabList
loading={loading}
tabListId={TAB_LIST_COMPONENT_ID}
tabs={TASK_TABS}
/>
</StyledTabListContainer>
{activeTabId === 'timeline' && (
<>
<TimelineQueryEffect targetableObject={targetableObject} />
<Timeline targetableObject={targetableObject} />
<Timeline loading={loading} targetableObject={targetableObject} />
</>
)}
{activeTabId === 'tasks' && (

View File

@ -1,5 +1,7 @@
import { ChangeEvent, ReactNode, useRef } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Tooltip } from 'react-tooltip';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Avatar, AvatarType } from 'twenty-ui';
import { v4 as uuidV4 } from 'uuid';
@ -18,9 +20,10 @@ type ShowPageSummaryCardProps = {
logoOrAvatar?: string;
onUploadPicture?: (file: File) => void;
title: ReactNode;
loading: boolean;
};
const StyledShowPageSummaryCard = styled.div`
export const StyledShowPageSummaryCard = styled.div`
align-items: center;
display: flex;
flex-direction: column;
@ -28,6 +31,7 @@ const StyledShowPageSummaryCard = styled.div`
justify-content: center;
padding: ${({ theme }) => theme.spacing(4)};
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
height: 127px;
`;
const StyledInfoContainer = styled.div`
@ -70,6 +74,30 @@ const StyledFileInput = styled.input`
display: none;
`;
const StyledSubSkeleton = styled.div`
align-items: center;
display: flex;
height: 37px;
justify-content: center;
width: 108px;
`;
const StyledShowPageSummaryCardSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton width={40} height={40} />
<StyledSubSkeleton>
<Skeleton width={96} height={16} />
</StyledSubSkeleton>
</SkeletonTheme>
);
};
export const ShowPageSummaryCard = ({
avatarPlaceholder,
avatarType,
@ -78,6 +106,7 @@ export const ShowPageSummaryCard = ({
logoOrAvatar,
onUploadPicture,
title,
loading,
}: ShowPageSummaryCardProps) => {
const beautifiedCreatedAt =
date !== '' ? beautifyPastDateRelativeToNow(date) : '';
@ -93,6 +122,13 @@ export const ShowPageSummaryCard = ({
inputFileRef?.current?.click?.();
};
if (loading)
return (
<StyledShowPageSummaryCard>
<StyledShowPageSummaryCardSkeletonLoader />
</StyledShowPageSummaryCard>
);
return (
<StyledShowPageSummaryCard>
<StyledAvatarWrapper>

View File

@ -21,6 +21,7 @@ type SingleTabProps = {
type TabListProps = {
tabListId: string;
tabs: SingleTabProps[];
loading?: boolean;
};
const StyledContainer = styled.div`
@ -33,7 +34,7 @@ const StyledContainer = styled.div`
user-select: none;
`;
export const TabList = ({ tabs, tabListId }: TabListProps) => {
export const TabList = ({ tabs, tabListId, loading }: TabListProps) => {
const initialActiveTabId = tabs[0].id;
const { activeTabIdState, setActiveTabId } = useTabList(tabListId);
@ -60,7 +61,7 @@ export const TabList = ({ tabs, tabListId }: TabListProps) => {
onClick={() => {
setActiveTabId(tab.id);
}}
disabled={tab.disabled}
disabled={tab.disabled ?? loading}
hasBetaPill={tab.hasBetaPill}
/>
))}

View File

@ -110,31 +110,30 @@ export const RecordShowPage = () => {
Icon={headerIcon}
loading={loading}
>
{record && (
<>
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
<ShowPageAddButton
key="add"
activityTargetObject={{
id: record.id,
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
/>
<ShowPageMoreButton
key="more"
recordId={record.id}
objectNameSingular={objectNameSingular}
/>
</>
)}
<>
<PageFavoriteButton
isFavorite={isFavorite}
onClick={handleFavoriteButtonClick}
/>
<ShowPageAddButton
key="add"
activityTargetObject={{
id: record?.id ?? '0',
targetObjectNameSingular: objectMetadataItem?.nameSingular,
}}
/>
<ShowPageMoreButton
key="more"
recordId={record?.id ?? '0'}
objectNameSingular={objectNameSingular}
/>
</>
</PageHeader>
<PageBody>
<RecordShowContainer
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
loading={loading}
/>
</PageBody>
</PageContainer>