1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +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,
expandable = false,
debounceTime = 200,
...rest
}: ISearchProps) => {
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLInputElement>(null);
@ -126,6 +127,7 @@ export const Search = ({
ref={searchContainerRef}
style={containerStyles}
active={expandable && showSuggestions}
{...rest}
>
<StyledSearch className={className}>
<SearchIcon

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -62,6 +62,7 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ListItemType } from './ProjectFeatureToggles.types';
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
import useLoading from 'hooks/useLoading';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap',
@ -73,6 +74,7 @@ interface IPaginatedProjectFeatureTogglesProps {
loading: boolean;
onChange: () => void;
total?: number;
initialLoad: boolean;
searchValue: string;
setSearchValue: React.Dispatch<React.SetStateAction<string>>;
paginationBar: JSX.Element;
@ -87,6 +89,7 @@ const defaultSort: SortingRule<string> & {
export const PaginatedProjectFeatureToggles = ({
features,
loading,
initialLoad,
environments: newEnvironments = [],
onChange,
total,
@ -95,6 +98,8 @@ export const PaginatedProjectFeatureToggles = ({
paginationBar,
}: IPaginatedProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles();
const bodyLoadingRef = useLoading(loading);
const headerLoadingRef = useLoading(initialLoad);
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [strategiesDialogState, setStrategiesDialogState] = useState({
@ -198,7 +203,10 @@ export const PaginatedProjectFeatureToggles = ({
accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => {
return showEnvironmentLastSeen ? (
<MemoizedFeatureEnvironmentSeenCell feature={feature} />
<MemoizedFeatureEnvironmentSeenCell
feature={feature}
data-loading
/>
) : (
<FeatureSeenCell value={value} />
);
@ -355,15 +363,20 @@ export const PaginatedProjectFeatureToggles = ({
);
const data = useMemo(() => {
if (loading) {
return Array(6).fill({
type: '-',
name: 'Feature name',
createdAt: new Date(),
environments: {
production: { name: 'production', enabled: false },
},
}) as FeatureSchema[];
if (initialLoad || loading) {
const loadingData = Array(15)
.fill(null)
.map((_, index) => ({
id: index, // Assuming `id` is a required property
type: '-',
name: `Feature name ${index}`,
createdAt: new Date().toISOString(),
environments: {
production: { name: 'production', enabled: false },
},
}));
// Coerce loading data to FeatureSchema[]
return loadingData as unknown as FeatureSchema[];
}
return featuresData;
}, [loading, featuresData]);
@ -491,168 +504,193 @@ export const PaginatedProjectFeatureToggles = ({
return (
<>
<PageContent
isLoading={loading}
disableLoading
className={styles.container}
sx={{ borderBottomLeftRadius: 0, borderBottomRightRadius: 0 }}
header={
<PageHeader
titleElement={
showTitle
? `Feature toggles (${total || rows.length})`
: null
}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
placeholder='Search and Filter'
expandable
initialValue={searchValue}
onChange={setSearchValue}
onFocus={() => setShowTitle(false)}
onBlur={() => setShowTitle(true)}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
}
/>
<ColumnsMenu
allColumns={allColumns}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={Boolean(storedParams.columns)}
setHiddenColumns={setHiddenColumns}
/>
<PageHeader.Divider sx={{ marginLeft: 0 }} />
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.featuresExportImport,
)}
show={
<Tooltip
title='Export toggles visible in the table below'
arrow
>
<IconButton
onClick={() =>
setShowExportDialog(true)
}
sx={(theme) => ({
marginRight:
theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
}
/>
<StyledResponsiveButton
onClick={() =>
navigate(getCreateTogglePath(projectId))
}
maxWidth='960px'
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
data-testid='NAVIGATE_TO_CREATE_FEATURE'
>
New feature toggle
</StyledResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
<div ref={headerLoadingRef}>
<PageHeader
titleElement={
showTitle
? `Feature toggles (${
total || rows.length
})`
: null
}
/>
</PageHeader>
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
data-loading
placeholder='Search and Filter'
expandable
initialValue={searchValue}
onChange={setSearchValue}
onFocus={() =>
setShowTitle(false)
}
onBlur={() =>
setShowTitle(true)
}
hasFilters
getSearchContext={
getSearchContext
}
id='projectFeatureToggles'
/>
}
/>
<ColumnsMenu
allColumns={allColumns}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={Boolean(
storedParams.columns,
)}
setHiddenColumns={setHiddenColumns}
/>
<PageHeader.Divider
sx={{ marginLeft: 0 }}
/>
<ConditionallyRender
condition={Boolean(
uiConfig?.flags
?.featuresExportImport,
)}
show={
<Tooltip
title='Export toggles visible in the table below'
arrow
>
<IconButton
data-loading
onClick={() =>
setShowExportDialog(
true,
)
}
sx={(theme) => ({
marginRight:
theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
}
/>
<StyledResponsiveButton
onClick={() =>
navigate(
getCreateTogglePath(projectId),
)
}
maxWidth='960px'
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
data-testid='NAVIGATE_TO_CREATE_FEATURE'
>
New feature toggle
</StyledResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
}
/>
</PageHeader>
</div>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
<div ref={bodyLoadingRef}>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching
&ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No feature toggles available. Get
started by adding a new feature toggle.
</TablePlaceholder>
}
/>
}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No feature toggles available. Get started by
adding a new feature toggle.
</TablePlaceholder>
}
/>
}
/>
<EnvironmentStrategyDialog
onClose={() =>
setStrategiesDialogState((prev) => ({
...prev,
open: false,
}))
}
projectId={projectId}
{...strategiesDialogState}
/>
<FeatureStaleDialog
isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)}
onClose={() => {
setFeatureStaleDialogState({});
onChange();
}}
featureId={featureStaleDialogState.featureId || ''}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={Boolean(featureArchiveState)}
onConfirm={onChange}
onClose={() => {
setFeatureArchiveState(undefined);
}}
featureIds={[featureArchiveState || '']}
projectId={projectId}
/>
<ConditionallyRender
condition={
Boolean(uiConfig?.flags?.featuresExportImport) &&
!loading
}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={data}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
{featureToggleModals}
<EnvironmentStrategyDialog
onClose={() =>
setStrategiesDialogState((prev) => ({
...prev,
open: false,
}))
}
projectId={projectId}
{...strategiesDialogState}
/>
<FeatureStaleDialog
isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)}
onClose={() => {
setFeatureStaleDialogState({});
onChange();
}}
featureId={featureStaleDialogState.featureId || ''}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={Boolean(featureArchiveState)}
onConfirm={onChange}
onClose={() => {
setFeatureArchiveState(undefined);
}}
featureIds={[featureArchiveState || '']}
projectId={projectId}
/>
<ConditionallyRender
condition={
Boolean(uiConfig?.flags?.featuresExportImport) &&
!loading
}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={data}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
{featureToggleModals}
</div>
</PageContent>
{paginationBar}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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