Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/List.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ You can find more advanced examples of `<List>` usage in the [demos](./Demos.md)
| `filters` | Optional | `ReactElement` | - | The filters to display in the toolbar. |
| `filter` | Optional | `object` | - | The permanent filter values. |
| `filter DefaultValues` | Optional | `object` | - | The default filter values. |
| `offline` | Optional | `ReactNode` | `<Offline>` | The component to render when there is no connectivity and there is no data in the cache |
| `pagination` | Optional | `ReactElement` | `<Pagination>` | The pagination component to use. |
| `perPage` | Optional | `number` | `10` | The number of records to fetch per page. |
| `queryOptions` | Optional | `object` | - | The options to pass to the `useQuery` hook. |
Expand Down Expand Up @@ -774,6 +775,43 @@ export const PostList = () => (
const filterSentToDataProvider = { ...filterDefaultValues, ...filterChosenByUser, ...filter };
```

## `offline`

By default, `<List>` renders the `<Offline>` component when there is no connectivity and there are no records in the cache yet for the current parameters (page, sort, etc.). You can provide your own component via the `offline` prop:

```jsx
import { List } from 'react-admin';
import { Alert } from '@mui/material';

const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;

export const PostList = () => (
<List offline={offline}>
...
</List>
);
```

**Tip**: If the record is in the Tanstack Query cache but you want to warn the user that they may see an outdated version, you can use the `<IsOffline>` component:

```jsx
import { List, IsOffline } from 'react-admin';
import { Alert } from '@mui/material';

const offline = <Alert severity="warning">No network. Could not load the posts.</Alert>;

export const PostList = () => (
<List offline={offline}>
<IsOffline>
<Alert severity="warning">
You are offline, the data may be outdated
</Alert>
</IsOffline>
...
</List>
);
```

## `pagination`

By default, the `<List>` view displays a set of pagination controls at the bottom of the list.
Expand Down
1 change: 1 addition & 0 deletions docs/ListBase.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ The `<ListBase>` component accepts the following props:
* [`exporter`](./List.md#exporter)
* [`filter`](./List.md#filter-permanent-filter)
* [`filterDefaultValues`](./List.md#filterdefaultvalues)
* [`offline`](./List.md#offline)
* [`perPage`](./List.md#perpage)
* [`queryOptions`](./List.md#queryoptions)
* `render`
Expand Down
21 changes: 21 additions & 0 deletions packages/ra-core/src/controller/list/ListBase.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AccessControl,
DefaultTitle,
NoAuthProvider,
Offline,
WithAuthProviderNoAccessControl,
WithRenderProps,
} from './ListBase.stories';
Expand Down Expand Up @@ -116,4 +117,24 @@ describe('ListBase', () => {
expect(dataProvider.getList).toHaveBeenCalled();
await screen.findByText('Hello');
});

it('should render the offline prop node when offline', async () => {
const { rerender } = render(<Offline isOnline={false} />);
await screen.findByText('You are offline, cannot load data');
rerender(<Offline isOnline={true} />);
await screen.findByText('War and Peace');
expect(
screen.queryByText('You are offline, cannot load data')
).toBeNull();
rerender(<Offline isOnline={false} />);
await screen.findByText('You are offline, the data may be outdated');
fireEvent.click(screen.getByText('next'));
await screen.findByText('You are offline, cannot load data');
rerender(<Offline isOnline={true} />);
await screen.findByText('And Then There Were None');
rerender(<Offline isOnline={false} />);
fireEvent.click(screen.getByText('previous'));
await screen.findByText('War and Peace');
await screen.findByText('You are offline, the data may be outdated');
});
});
94 changes: 92 additions & 2 deletions packages/ra-core/src/controller/list/ListBase.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import {
AuthProvider,
DataProvider,
I18nProvider,
IsOffline,
ListBaseProps,
mergeTranslations,
useLocaleState,
} from '../..';
import { onlineManager } from '@tanstack/react-query';

export default {
title: 'ra-core/controller/list/ListBase',
Expand Down Expand Up @@ -48,7 +51,11 @@ const data = {
],
};

const defaultDataProvider = fakeRestProvider(data, true, 300);
const defaultDataProvider = fakeRestProvider(
data,
process.env.NODE_ENV !== 'test',
300
);

const BookListView = () => {
const {
Expand Down Expand Up @@ -333,7 +340,7 @@ export const WithRenderProps = ({
</div>
);
}}
></ListBase>
/>
</CoreAdminContext>
);

Expand All @@ -347,6 +354,89 @@ DefaultTitle.argTypes = {
},
};

export const Offline = ({
dataProvider = defaultDataProvider,
isOnline = true,
...props
}: {
dataProvider?: DataProvider;
isOnline?: boolean;
} & Partial<ListBaseProps>) => {
React.useEffect(() => {
onlineManager.setOnline(isOnline);
}, [isOnline]);
return (
<CoreAdminContext dataProvider={dataProvider}>
<ListBase
resource="books"
perPage={5}
{...props}
offline={<p>You are offline, cannot load data</p>}
render={controllerProps => {
const {
data,
error,
isPending,
page,
perPage,
setPage,
total,
} = controllerProps;
if (isPending) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error...</div>;
}

return (
<div>
<p>
Use the story controls to simulate offline mode:
</p>
<IsOffline>
<p style={{ color: 'orange' }}>
You are offline, the data may be outdated
</p>
</IsOffline>
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
previous
</button>
<span>
Page {page} of {Math.ceil(total / perPage)}
</span>
<button
disabled={page >= total / perPage}
onClick={() => setPage(page + 1)}
>
next
</button>
<ul>
{data.map((record: any) => (
<li key={record.id}>{record.title}</li>
))}
</ul>
</div>
);
}}
/>
</CoreAdminContext>
);
};

Offline.args = {
isOnline: true,
};

Offline.argTypes = {
isOnline: {
control: { type: 'boolean' },
},
};

const Title = () => {
const { defaultTitle } = useListContext();
const [locale, setLocale] = useLocaleState();
Expand Down
31 changes: 26 additions & 5 deletions packages/ra-core/src/controller/list/ListBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ import { useIsAuthPending } from '../../auth';
*/
export const ListBase = <RecordType extends RaRecord = any>({
children,
disableAuthentication,
render,
loading = null,
loading,
offline,
...props
}: ListBaseProps<RecordType>) => {
const controllerProps = useListController<RecordType>(props);
Expand All @@ -56,20 +58,38 @@ export const ListBase = <RecordType extends RaRecord = any>({
action: 'list',
});

if (isAuthPending && !props.disableAuthentication) {
return loading;
}
if (!render && !children) {
throw new Error(
"<ListBase> requires either a 'render' prop or 'children' prop"
);
}

const showLoading =
isAuthPending &&
!disableAuthentication &&
loading !== undefined &&
loading !== false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this change, the content was never shown when isAuthPending was true. Now, it can be. I know we did the same with ShowBase and EditBase, but I wanted to be sure that it is done on purpose: if someone provided null to hide the content while authentication is pending, it will now show the content instead of hiding it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually realize I introduced the same regression in EditBase and ShowBase... PR imcoming. Regarding your comment, you're right and that's what we decided with @fzaninotto. Might be worth to discuss it again before releasing.


const { isPaused, isPending, isPlaceholderData } = controllerProps;
const showOffline =
isPaused &&
// If isPending and isPaused are true, we are offline and couldn't even load the initial data
// If isPaused and isPlaceholderData are true, we are offline and couldn't even load data with different parameters on the same useQuery observer
(isPending || isPlaceholderData) &&
offline !== undefined &&
offline !== false;

return (
// We pass props.resource here as we don't need to create a new ResourceContext if the props is not provided
<OptionalResourceContextProvider value={props.resource}>
<ListContextProvider value={controllerProps}>
{render ? render(controllerProps) : children}
{showLoading
? loading
: showOffline
? offline
: render
? render(controllerProps)
: children}
</ListContextProvider>
</OptionalResourceContextProvider>
);
Expand All @@ -80,4 +100,5 @@ export interface ListBaseProps<RecordType extends RaRecord = any>
children?: ReactNode;
render?: (props: ListControllerResult<RecordType, Error>) => ReactNode;
loading?: ReactNode;
offline?: ReactNode;
}
42 changes: 1 addition & 41 deletions packages/ra-core/src/controller/list/useListController.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
screen,
act,
} from '@testing-library/react';
import { onlineManager } from '@tanstack/react-query';
import { testDataProvider } from '../../dataProvider';
import { memoryStore } from '../../store';
import { CoreAdminContext } from '../../core';
Expand All @@ -23,11 +22,7 @@ import {
CanAccess,
DisableAuthentication,
} from './useListController.security.stories';
import {
Basic,
defaultDataProvider,
Offline,
} from './useListController.stories';
import { Basic, defaultDataProvider } from './useListController.stories';

describe('useListController', () => {
const defaultProps = {
Expand All @@ -36,10 +31,6 @@ describe('useListController', () => {
debounce: 200,
};

beforeEach(() => {
onlineManager.setOnline(true);
});

describe('queryOptions', () => {
it('should accept custom client query options', async () => {
jest.spyOn(console, 'error').mockImplementationOnce(() => {});
Expand Down Expand Up @@ -694,37 +685,6 @@ describe('useListController', () => {
});
});

describe('offline', () => {
it('should display a warning if showing placeholder data when offline', async () => {
render(<Offline />);
fireEvent.click(await screen.findByText('Go online'));
await screen.findByText('1 - Morbi suscipit malesuada');
fireEvent.click(await screen.findByText('Go offline'));
fireEvent.click(await screen.findByText('Page 2'));
await screen.findByText(
'ra.message.placeholder_data_warning - warning'
);
});

it('should not display a warning if showing stale data when offline', async () => {
render(<Offline />);
fireEvent.click(await screen.findByText('Go online'));
await screen.findByText('1 - Morbi suscipit malesuada');
fireEvent.click(await screen.findByText('Page 2'));
await screen.findByText('4 - Integer commodo est');
fireEvent.click(await screen.findByText('Page 1'));
await screen.findByText('1 - Morbi suscipit malesuada');
fireEvent.click(await screen.findByText('Go offline'));
fireEvent.click(await screen.findByText('Page 2'));
await screen.findByText('4 - Integer commodo est');
expect(
screen.queryByText(
'ra.message.placeholder_data_warning - warning'
)
).toBeNull();
});
});

describe('response metadata', () => {
it('should return response metadata as meta', async () => {
const getList = jest.fn().mockImplementation(() =>
Expand Down
Loading
Loading