1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

Feat/pagination loading (#5325)

This PR makes changes to how the project overview skeleton screen works.
Important changes:

- Add skeleton screens to missing elements, creating a more
comprehensive loading screen
- Split the page into different loading sections, so that we can load
the table when we fetch the next page without affecting the rest of the
page.

https://www.loom.com/share/e5d30dc897ac488ea80cfae11ffab646

Next steps:
* Hide bar if total is less than 25
* Add FE testing
This commit is contained in:
Fredrik Strand Oseberg 2023-11-13 14:08:48 +01:00 committed by GitHub
parent 6a41ee6e9d
commit 834ae1d8a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 286 additions and 210 deletions

View File

@ -81,6 +81,7 @@ export const Search = ({
containerStyles, containerStyles,
expandable = false, expandable = false,
debounceTime = 200, debounceTime = 200,
...rest
}: ISearchProps) => { }: ISearchProps) => {
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLInputElement>(null); const searchContainerRef = useRef<HTMLInputElement>(null);
@ -126,6 +127,7 @@ export const Search = ({
ref={searchContainerRef} ref={searchContainerRef}
style={containerStyles} style={containerStyles}
active={expandable && showSuggestions} active={expandable && showSuggestions}
{...rest}
> >
<StyledSearch className={className}> <StyledSearch className={className}>
<SearchIcon <SearchIcon

View File

@ -13,7 +13,7 @@ export const SortableTableHeader = <T extends object>({
}) => ( }) => (
<TableHead className={className}> <TableHead className={className}>
{headerGroups.map((headerGroup) => ( {headerGroups.map((headerGroup) => (
<TableRow {...headerGroup.getHeaderGroupProps()}> <TableRow {...headerGroup.getHeaderGroupProps()} data-loading>
{headerGroup.headers.map((column: HeaderGroup<T>) => { {headerGroup.headers.map((column: HeaderGroup<T>) => {
const content = column.render('Header'); const content = column.render('Header');

View File

@ -8,6 +8,7 @@ interface IFeatureSeenCellProps {
export const FeatureEnvironmentSeenCell: VFC<IFeatureSeenCellProps> = ({ export const FeatureEnvironmentSeenCell: VFC<IFeatureSeenCellProps> = ({
feature, feature,
...rest
}) => { }) => {
const environments = feature.environments const environments = feature.environments
? Object.values(feature.environments) ? Object.values(feature.environments)
@ -17,6 +18,7 @@ export const FeatureEnvironmentSeenCell: VFC<IFeatureSeenCellProps> = ({
<FeatureEnvironmentSeen <FeatureEnvironmentSeen
featureLastSeen={feature.lastSeenAt} featureLastSeen={feature.lastSeenAt}
environments={environments} environments={environments}
{...rest}
/> />
); );
}; };

View File

@ -72,7 +72,7 @@ export const LastSeenTooltip = ({
Boolean(environment.lastSeenAt), Boolean(environment.lastSeenAt),
); );
return ( return (
<StyledDescription {...rest}> <StyledDescription {...rest} data-loading>
<StyledDescriptionHeader sx={{ mb: 0 }}> <StyledDescriptionHeader sx={{ mb: 0 }}>
Last usage reported Last usage reported
</StyledDescriptionHeader> </StyledDescriptionHeader>

View File

@ -74,6 +74,7 @@ export const FeatureEnvironmentSeen = ({
featureLastSeen, featureLastSeen,
environments, environments,
sx, sx,
...rest
}: IFeatureEnvironmentSeenProps) => { }: IFeatureEnvironmentSeenProps) => {
const getColor = useLastSeenColors(); const getColor = useLastSeenColors();
@ -95,6 +96,7 @@ export const FeatureEnvironmentSeen = ({
<LastSeenTooltip <LastSeenTooltip
featureLastSeen={lastSeen} featureLastSeen={lastSeen}
environments={environments} environments={environments}
{...rest}
/> />
} }
color={color} color={color}

View File

@ -228,6 +228,7 @@ export const Project = () => {
{filteredTabs.map((tab) => { {filteredTabs.map((tab) => {
return ( return (
<StyledTab <StyledTab
data-loading
key={tab.title} key={tab.title}
label={tab.title} label={tab.title}
value={tab.path} value={tab.path}

View File

@ -87,7 +87,7 @@ export const ActionsCell: VFC<IActionsCellProps> = ({
}; };
return ( return (
<StyledBoxCell> <StyledBoxCell data-loading>
<Tooltip title='Feature toggle actions' arrow describeChild> <Tooltip title='Feature toggle actions' arrow describeChild>
<IconButton <IconButton
id={id} id={id}

View File

@ -62,6 +62,7 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ListItemType } from './ProjectFeatureToggles.types'; import { ListItemType } from './ProjectFeatureToggles.types';
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell'; import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch'; import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
import useLoading from 'hooks/useLoading';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@ -73,6 +74,7 @@ interface IPaginatedProjectFeatureTogglesProps {
loading: boolean; loading: boolean;
onChange: () => void; onChange: () => void;
total?: number; total?: number;
initialLoad: boolean;
searchValue: string; searchValue: string;
setSearchValue: React.Dispatch<React.SetStateAction<string>>; setSearchValue: React.Dispatch<React.SetStateAction<string>>;
paginationBar: JSX.Element; paginationBar: JSX.Element;
@ -87,6 +89,7 @@ const defaultSort: SortingRule<string> & {
export const PaginatedProjectFeatureToggles = ({ export const PaginatedProjectFeatureToggles = ({
features, features,
loading, loading,
initialLoad,
environments: newEnvironments = [], environments: newEnvironments = [],
onChange, onChange,
total, total,
@ -95,6 +98,8 @@ export const PaginatedProjectFeatureToggles = ({
paginationBar, paginationBar,
}: IPaginatedProjectFeatureTogglesProps) => { }: IPaginatedProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles(); const { classes: styles } = useStyles();
const bodyLoadingRef = useLoading(loading);
const headerLoadingRef = useLoading(initialLoad);
const theme = useTheme(); const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [strategiesDialogState, setStrategiesDialogState] = useState({ const [strategiesDialogState, setStrategiesDialogState] = useState({
@ -198,7 +203,10 @@ export const PaginatedProjectFeatureToggles = ({
accessor: 'lastSeenAt', accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => { Cell: ({ value, row: { original: feature } }: any) => {
return showEnvironmentLastSeen ? ( return showEnvironmentLastSeen ? (
<MemoizedFeatureEnvironmentSeenCell feature={feature} /> <MemoizedFeatureEnvironmentSeenCell
feature={feature}
data-loading
/>
) : ( ) : (
<FeatureSeenCell value={value} /> <FeatureSeenCell value={value} />
); );
@ -355,15 +363,20 @@ export const PaginatedProjectFeatureToggles = ({
); );
const data = useMemo(() => { const data = useMemo(() => {
if (loading) { if (initialLoad || loading) {
return Array(6).fill({ const loadingData = Array(15)
.fill(null)
.map((_, index) => ({
id: index, // Assuming `id` is a required property
type: '-', type: '-',
name: 'Feature name', name: `Feature name ${index}`,
createdAt: new Date(), createdAt: new Date().toISOString(),
environments: { environments: {
production: { name: 'production', enabled: false }, production: { name: 'production', enabled: false },
}, },
}) as FeatureSchema[]; }));
// Coerce loading data to FeatureSchema[]
return loadingData as unknown as FeatureSchema[];
} }
return featuresData; return featuresData;
}, [loading, featuresData]); }, [loading, featuresData]);
@ -491,14 +504,17 @@ export const PaginatedProjectFeatureToggles = ({
return ( return (
<> <>
<PageContent <PageContent
isLoading={loading} disableLoading
className={styles.container} className={styles.container}
sx={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }} sx={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
header={ header={
<div ref={headerLoadingRef}>
<PageHeader <PageHeader
titleElement={ titleElement={
showTitle showTitle
? `Feature toggles (${total || rows.length})` ? `Feature toggles (${
total || rows.length
})`
: null : null
} }
actions={ actions={
@ -507,14 +523,21 @@ export const PaginatedProjectFeatureToggles = ({
condition={!isSmallScreen} condition={!isSmallScreen}
show={ show={
<Search <Search
data-loading
placeholder='Search and Filter' placeholder='Search and Filter'
expandable expandable
initialValue={searchValue} initialValue={searchValue}
onChange={setSearchValue} onChange={setSearchValue}
onFocus={() => setShowTitle(false)} onFocus={() =>
onBlur={() => setShowTitle(true)} setShowTitle(false)
}
onBlur={() =>
setShowTitle(true)
}
hasFilters hasFilters
getSearchContext={getSearchContext} getSearchContext={
getSearchContext
}
id='projectFeatureToggles' id='projectFeatureToggles'
/> />
} }
@ -524,13 +547,18 @@ export const PaginatedProjectFeatureToggles = ({
staticColumns={staticColumns} staticColumns={staticColumns}
dividerAfter={['createdAt']} dividerAfter={['createdAt']}
dividerBefore={['Actions']} dividerBefore={['Actions']}
isCustomized={Boolean(storedParams.columns)} isCustomized={Boolean(
storedParams.columns,
)}
setHiddenColumns={setHiddenColumns} setHiddenColumns={setHiddenColumns}
/> />
<PageHeader.Divider sx={{ marginLeft: 0 }} /> <PageHeader.Divider
sx={{ marginLeft: 0 }}
/>
<ConditionallyRender <ConditionallyRender
condition={Boolean( condition={Boolean(
uiConfig?.flags?.featuresExportImport, uiConfig?.flags
?.featuresExportImport,
)} )}
show={ show={
<Tooltip <Tooltip
@ -538,8 +566,11 @@ export const PaginatedProjectFeatureToggles = ({
arrow arrow
> >
<IconButton <IconButton
data-loading
onClick={() => onClick={() =>
setShowExportDialog(true) setShowExportDialog(
true,
)
} }
sx={(theme) => ({ sx={(theme) => ({
marginRight: marginRight:
@ -553,7 +584,9 @@ export const PaginatedProjectFeatureToggles = ({
/> />
<StyledResponsiveButton <StyledResponsiveButton
onClick={() => onClick={() =>
navigate(getCreateTogglePath(projectId)) navigate(
getCreateTogglePath(projectId),
)
} }
maxWidth='960px' maxWidth='960px'
Icon={Add} Icon={Add}
@ -579,8 +612,10 @@ export const PaginatedProjectFeatureToggles = ({
} }
/> />
</PageHeader> </PageHeader>
</div>
} }
> >
<div ref={bodyLoadingRef}>
<SearchHighlightProvider value={getSearchText(searchValue)}> <SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable <VirtualizedTable
rows={rows} rows={rows}
@ -588,6 +623,7 @@ export const PaginatedProjectFeatureToggles = ({
prepareRow={prepareRow} prepareRow={prepareRow}
/> />
</SearchHighlightProvider> </SearchHighlightProvider>
<ConditionallyRender <ConditionallyRender
condition={rows.length === 0} condition={rows.length === 0}
show={ show={
@ -595,15 +631,16 @@ export const PaginatedProjectFeatureToggles = ({
condition={searchValue?.length > 0} condition={searchValue?.length > 0}
show={ show={
<TablePlaceholder> <TablePlaceholder>
No feature toggles found matching &ldquo; No feature toggles found matching
&ldquo;
{searchValue} {searchValue}
&rdquo; &rdquo;
</TablePlaceholder> </TablePlaceholder>
} }
elseShow={ elseShow={
<TablePlaceholder> <TablePlaceholder>
No feature toggles available. Get started by No feature toggles available. Get
adding a new feature toggle. started by adding a new feature toggle.
</TablePlaceholder> </TablePlaceholder>
} }
/> />
@ -653,6 +690,7 @@ export const PaginatedProjectFeatureToggles = ({
} }
/> />
{featureToggleModals} {featureToggleModals}
</div>
</PageContent> </PageContent>
{paginationBar} {paginationBar}

View File

@ -20,7 +20,7 @@ export const RowSelectCell: FC<IRowSelectCellProps> = ({
checked, checked,
title, title,
}) => ( }) => (
<StyledBoxCell data-testid={BATCH_SELECT}> <StyledBoxCell data-testid={BATCH_SELECT} data-loading>
<Checkbox onChange={onChange} title={title} checked={checked} /> <Checkbox onChange={onChange} title={title} checked={checked} />
</StyledBoxCell> </StyledBoxCell>
); );

View File

@ -40,7 +40,7 @@ export const HealthWidget = ({ projectId, health }: IHealthWidgetProps) => {
gap: (theme) => theme.spacing(2), gap: (theme) => theme.spacing(2),
}} }}
> >
<StyledPercentageText> <StyledPercentageText data-loading>
<PercentageCircle percentage={health} /> <PercentageCircle percentage={health} />
</StyledPercentageText> </StyledPercentageText>
<StyledParagraphEmphasizedText data-loading> <StyledParagraphEmphasizedText data-loading>

View File

@ -24,8 +24,8 @@ const StyledIDContainer = styled('div')(({ theme }) => ({
export const MetaWidget: FC<IMetaWidgetProps> = ({ id, description }) => { export const MetaWidget: FC<IMetaWidgetProps> = ({ id, description }) => {
return ( return (
<StyledProjectInfoWidgetContainer> <StyledProjectInfoWidgetContainer>
<StyledWidgetTitle>Project Meta</StyledWidgetTitle> <StyledWidgetTitle data-loading>Project Meta</StyledWidgetTitle>
<StyledIDContainer> <StyledIDContainer data-loading>
<Typography <Typography
component='span' component='span'
variant='body2' variant='body2'
@ -39,6 +39,7 @@ export const MetaWidget: FC<IMetaWidgetProps> = ({ id, description }) => {
condition={Boolean(description)} condition={Boolean(description)}
show={ show={
<Typography <Typography
data-loading
variant='body2' variant='body2'
sx={{ sx={{
marginTop: (theme) => theme.spacing(1.5), marginTop: (theme) => theme.spacing(1.5),

View File

@ -30,6 +30,7 @@ export const ProjectMembersWidget = ({
<StyledProjectInfoWidgetContainer> <StyledProjectInfoWidgetContainer>
<StyledWidgetTitle data-loading>Project members</StyledWidgetTitle> <StyledWidgetTitle data-loading>Project members</StyledWidgetTitle>
<Box <Box
data-loading
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
@ -37,7 +38,9 @@ export const ProjectMembersWidget = ({
> >
<StatusBox boxText={`${memberCount}`} change={change} /> <StatusBox boxText={`${memberCount}`} change={change} />
</Box> </Box>
<WidgetFooterLink to={link}>View all members</WidgetFooterLink> <WidgetFooterLink data-loading to={link}>
View all members
</WidgetFooterLink>
</StyledProjectInfoWidgetContainer> </StyledProjectInfoWidgetContainer>
); );
}; };

View File

@ -84,7 +84,9 @@ export const ToggleTypesWidget = ({ features }: IToggleTypesWidgetProps) => {
<StyledProjectInfoWidgetContainer <StyledProjectInfoWidgetContainer
sx={{ padding: (theme) => theme.spacing(3) }} sx={{ padding: (theme) => theme.spacing(3) }}
> >
<StyledWidgetTitle>Toggle types used</StyledWidgetTitle> <StyledWidgetTitle data-loading>
Toggle types used
</StyledWidgetTitle>
{Object.keys(featureTypeStats).map((type) => ( {Object.keys(featureTypeStats).map((type) => (
<ToggleTypesRow <ToggleTypesRow
type={type} type={type}

View File

@ -12,6 +12,7 @@ export const WidgetFooterLink: FC<IWidgetFooterLinkProps> = ({
}) => { }) => {
return ( return (
<Typography <Typography
data-loading
variant='body2' variant='body2'
textAlign='center' textAlign='center'
sx={{ sx={{

View File

@ -45,7 +45,7 @@ const PaginatedProjectOverview = () => {
const { project, loading: projectLoading } = useProject(projectId, { const { project, loading: projectLoading } = useProject(projectId, {
refreshInterval, refreshInterval,
}); });
const [pageLimit, setPageLimit] = useState(10); const [pageLimit, setPageLimit] = useState(25);
const [currentOffset, setCurrentOffset] = useState(0); const [currentOffset, setCurrentOffset] = useState(0);
const [searchValue, setSearchValue] = useState( const [searchValue, setSearchValue] = useState(
@ -57,6 +57,7 @@ const PaginatedProjectOverview = () => {
total, total,
refetch, refetch,
loading, loading,
initialLoad,
} = useFeatureSearch(currentOffset, pageLimit, projectId, searchValue, { } = useFeatureSearch(currentOffset, pageLimit, projectId, searchValue, {
refreshInterval, refreshInterval,
}); });
@ -96,6 +97,7 @@ const PaginatedProjectOverview = () => {
} }
features={searchFeatures} features={searchFeatures}
environments={environments} environments={environments}
initialLoad={initialLoad && searchFeatures.length === 0}
loading={loading && searchFeatures.length === 0} loading={loading && searchFeatures.length === 0}
onChange={refetch} onChange={refetch}
total={total} total={total}
@ -128,7 +130,7 @@ const StyledStickyBar = styled('div')(({ theme }) => ({
backgroundColor: theme.palette.background.paper, backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2), padding: theme.spacing(2),
marginLeft: theme.spacing(2), marginLeft: theme.spacing(2),
zIndex: theme.zIndex.mobileStepper, zIndex: 9999,
borderBottomLeftRadius: theme.shape.borderRadiusMedium, borderBottomLeftRadius: theme.shape.borderRadiusMedium,
borderBottomRightRadius: theme.shape.borderRadiusMedium, borderBottomRightRadius: theme.shape.borderRadiusMedium,
borderTop: `1px solid ${theme.palette.divider}`, borderTop: `1px solid ${theme.palette.divider}`,

View File

@ -65,7 +65,11 @@ export const StatusBox: FC<IStatusBoxProps> = ({
<> <>
<ConditionallyRender <ConditionallyRender
condition={Boolean(title)} condition={Boolean(title)}
show={<StyledTypographyHeader>{title}</StyledTypographyHeader>} show={
<StyledTypographyHeader data-loading>
{title}
</StyledTypographyHeader>
}
/> />
{children} {children}
<Box <Box
@ -75,11 +79,13 @@ export const StatusBox: FC<IStatusBoxProps> = ({
width: 'auto', width: 'auto',
}} }}
> >
<StyledTypographyCount>{boxText}</StyledTypographyCount> <StyledTypographyCount data-loading>
{boxText}
</StyledTypographyCount>
<ConditionallyRender <ConditionallyRender
condition={Boolean(customChangeElement)} condition={Boolean(customChangeElement)}
show={ show={
<StyledBoxChangeContainer> <StyledBoxChangeContainer data-loading>
{customChangeElement} {customChangeElement}
</StyledBoxChangeContainer> </StyledBoxChangeContainer>
} }
@ -87,7 +93,7 @@ export const StatusBox: FC<IStatusBoxProps> = ({
<ConditionallyRender <ConditionallyRender
condition={change !== undefined && change !== 0} condition={change !== undefined && change !== 0}
show={ show={
<StyledBoxChangeContainer> <StyledBoxChangeContainer data-loading>
<Box <Box
sx={{ sx={{
...flexRow, ...flexRow,
@ -109,7 +115,7 @@ export const StatusBox: FC<IStatusBoxProps> = ({
} }
elseShow={ elseShow={
<StyledBoxChangeContainer> <StyledBoxChangeContainer>
<StyledTypographySubtext> <StyledTypographySubtext data-loading>
No change No change
</StyledTypographySubtext> </StyledTypographySubtext>
</StyledBoxChangeContainer> </StyledBoxChangeContainer>

View File

@ -119,6 +119,7 @@ exports[`renders an empty list correctly 1`] = `
> >
<tr <tr
className="MuiTableRow-root MuiTableRow-head css-15lapfi-MuiTableRow-root" className="MuiTableRow-root MuiTableRow-head css-15lapfi-MuiTableRow-root"
data-loading={true}
role="row" role="row"
> >
<th <th

View File

@ -14,6 +14,7 @@ interface IUseFeatureSearchOutput {
features: IFeatureToggleListItem[]; features: IFeatureToggleListItem[];
total: number; total: number;
loading: boolean; loading: boolean;
initialLoad: boolean;
error: string; error: string;
refetch: () => void; refetch: () => void;
} }
@ -26,38 +27,52 @@ const fallbackData: {
total: 0, total: 0,
}; };
export const useFeatureSearch = ( const createFeatureSearch = () => {
let total = 0;
let initialLoad = true;
return (
offset: number, offset: number,
limit: number, limit: number,
projectId = '', projectId = '',
searchValue = '', searchValue = '',
options: SWRConfiguration = {}, options: SWRConfiguration = {},
): IUseFeatureSearchOutput => { ): IUseFeatureSearchOutput => {
const { KEY, fetcher } = getFeatureSearchFetcher( const { KEY, fetcher } = getFeatureSearchFetcher(
projectId, projectId,
offset, offset,
limit, limit,
searchValue, searchValue,
); );
const { data, error, mutate } = useSWR<IFeatureSearchResponse>( const { data, error, mutate, isLoading } =
KEY, useSWR<IFeatureSearchResponse>(KEY, fetcher, options);
fetcher,
options,
);
const refetch = useCallback(() => { const refetch = useCallback(() => {
mutate(); mutate();
}, [mutate]); }, [mutate]);
if (data?.total) {
total = data.total;
}
if (!isLoading && initialLoad) {
initialLoad = false;
}
const returnData = data || fallbackData; const returnData = data || fallbackData;
return { return {
...returnData, ...returnData,
loading: false, loading: isLoading,
error, error,
refetch, refetch,
total,
initialLoad: isLoading && initialLoad,
};
}; };
}; };
export const useFeatureSearch = createFeatureSearch();
const getFeatureSearchFetcher = ( const getFeatureSearchFetcher = (
projectId: string, projectId: string,
offset: number, offset: number,

View File

@ -12,7 +12,7 @@ const useLoading = (loading: boolean, selector = '[data-loading=true]') => {
if (loading) { if (loading) {
element.classList.add('skeleton'); element.classList.add('skeleton');
} else { } else {
element.classList.remove('skeleton'); setTimeout(() => element.classList.remove('skeleton'), 10);
} }
}); });
} }

View File

@ -36,7 +36,7 @@ button {
.skeleton { .skeleton {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
z-index: 9999; z-index: 9990;
box-shadow: none; box-shadow: none;
fill: none; fill: none;
pointer-events: none; pointer-events: none;