feat: Error state for paginated lists (#3766)
* Add error state for failed list loading * Move sidebar collections to PaginatedList for improved error handling, loading, retrying etc
This commit is contained in:
@@ -1,17 +1,6 @@
|
|||||||
import * as React from "react";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
type Props = {
|
const ButtonLink = styled.button`
|
||||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ButtonLink: React.FC<Props> = React.forwardRef(
|
|
||||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
|
||||||
return <Button {...props} ref={ref} />;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const Button = styled.button`
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import styled from "styled-components";
|
|||||||
|
|
||||||
const Empty = styled.p`
|
const Empty = styled.p`
|
||||||
color: ${(props) => props.theme.textTertiary};
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
user-select: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Empty;
|
export default Empty;
|
||||||
|
|||||||
53
app/components/List/Error.tsx
Normal file
53
app/components/List/Error.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import Empty from "~/components/Empty";
|
||||||
|
import useEventListener from "~/hooks/useEventListener";
|
||||||
|
import { OfflineError } from "~/utils/errors";
|
||||||
|
import ButtonLink from "../ButtonLink";
|
||||||
|
import Flex from "../Flex";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
error: Error;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoadingError({ error, retry, ...rest }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useEventListener("online", retry);
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof OfflineError ? (
|
||||||
|
<>
|
||||||
|
<DisconnectedIcon color="currentColor" /> {t("You’re offline.")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Content {...rest}>
|
||||||
|
<Flex align="center" gap={4}>
|
||||||
|
{message}{" "}
|
||||||
|
<ButtonLink onClick={() => retry()}>{t("Click to retry")}…</ButtonLink>
|
||||||
|
</Flex>
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Content = styled(Empty)`
|
||||||
|
padding: 8px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
${ButtonLink} {
|
||||||
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ${(props) => props.theme.textSecondary};
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import DocumentListItem from "~/components/DocumentListItem";
|
import DocumentListItem from "~/components/DocumentListItem";
|
||||||
|
import Error from "~/components/List/Error";
|
||||||
import PaginatedList from "~/components/PaginatedList";
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -40,6 +41,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
|||||||
heading={heading}
|
heading={heading}
|
||||||
fetch={fetch}
|
fetch={fetch}
|
||||||
options={options}
|
options={options}
|
||||||
|
renderError={(props) => <Error {...props} />}
|
||||||
renderItem={(item: Document, _index, compositeProps) => (
|
renderItem={(item: Document, _index, compositeProps) => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -34,12 +34,19 @@ type Props<T> = WithTranslation &
|
|||||||
index: number,
|
index: number,
|
||||||
compositeProps: CompositeStateReturn
|
compositeProps: CompositeStateReturn
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
renderError?: (options: {
|
||||||
|
error: Error;
|
||||||
|
retry: () => void;
|
||||||
|
}) => React.ReactNode;
|
||||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||||
|
@observable
|
||||||
|
error?: Error;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
isFetchingMore = false;
|
isFetchingMore = false;
|
||||||
|
|
||||||
@@ -80,6 +87,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
|||||||
this.isFetchingMore = false;
|
this.isFetchingMore = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
fetchResults = async () => {
|
fetchResults = async () => {
|
||||||
if (!this.props.fetch) {
|
if (!this.props.fetch) {
|
||||||
return;
|
return;
|
||||||
@@ -87,25 +95,30 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
|||||||
this.isFetching = true;
|
this.isFetching = true;
|
||||||
const counter = ++this.fetchCounter;
|
const counter = ++this.fetchCounter;
|
||||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||||
|
this.error = undefined;
|
||||||
|
|
||||||
const results = await this.props.fetch({
|
try {
|
||||||
limit,
|
const results = await this.props.fetch({
|
||||||
offset: this.offset,
|
limit,
|
||||||
...this.props.options,
|
offset: this.offset,
|
||||||
});
|
...this.props.options,
|
||||||
|
});
|
||||||
|
|
||||||
if (results && (results.length === 0 || results.length < limit)) {
|
if (results && (results.length === 0 || results.length < limit)) {
|
||||||
this.allowLoadMore = false;
|
this.allowLoadMore = false;
|
||||||
} else {
|
} else {
|
||||||
this.offset += limit;
|
this.offset += limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.renderCount += limit;
|
this.renderCount += limit;
|
||||||
|
} catch (err) {
|
||||||
// only the most recent fetch should end the loading state
|
this.error = err;
|
||||||
if (counter >= this.fetchCounter) {
|
} finally {
|
||||||
this.isFetching = false;
|
// only the most recent fetch should end the loading state
|
||||||
this.isFetchingMore = false;
|
if (counter >= this.fetchCounter) {
|
||||||
|
this.isFetching = false;
|
||||||
|
this.isFetchingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,7 +151,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
|||||||
auth,
|
auth,
|
||||||
empty = null,
|
empty = null,
|
||||||
renderHeading,
|
renderHeading,
|
||||||
|
renderError,
|
||||||
onEscape,
|
onEscape,
|
||||||
|
children,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const showLoading =
|
const showLoading =
|
||||||
@@ -157,6 +172,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items?.length === 0) {
|
if (items?.length === 0) {
|
||||||
|
if (this.error && renderError) {
|
||||||
|
return renderError({ error: this.error, retry: this.fetchResults });
|
||||||
|
}
|
||||||
|
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +224,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
</ArrowKeyNavigation>
|
</ArrowKeyNavigation>
|
||||||
|
{children}
|
||||||
{this.allowLoadMore && (
|
{this.allowLoadMore && (
|
||||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { observer } from "mobx-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useDrop } from "react-dnd";
|
import { useDrop } from "react-dnd";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Fade from "~/components/Fade";
|
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import Error from "~/components/List/Error";
|
||||||
|
import PaginatedList from "~/components/PaginatedList";
|
||||||
|
import Text from "~/components/Text";
|
||||||
import { createCollection } from "~/actions/definitions/collections";
|
import { createCollection } from "~/actions/definitions/collections";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
|
||||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||||
import DropCursor from "./DropCursor";
|
import DropCursor from "./DropCursor";
|
||||||
import Header from "./Header";
|
import Header from "./Header";
|
||||||
@@ -18,39 +20,10 @@ import SidebarAction from "./SidebarAction";
|
|||||||
import { DragObject } from "./SidebarLink";
|
import { DragObject } from "./SidebarLink";
|
||||||
|
|
||||||
function Collections() {
|
function Collections() {
|
||||||
const [isFetching, setFetching] = React.useState(false);
|
|
||||||
const [fetchError, setFetchError] = React.useState();
|
|
||||||
const { documents, collections } = useStores();
|
const { documents, collections } = useStores();
|
||||||
const { showToast } = useToasts();
|
|
||||||
const isPreloaded = !!collections.orderedData.length;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const orderedCollections = collections.orderedData;
|
const orderedCollections = collections.orderedData;
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
async function load() {
|
|
||||||
if (!collections.isLoaded && !isFetching && !fetchError) {
|
|
||||||
try {
|
|
||||||
setFetching(true);
|
|
||||||
await collections.fetchPage({
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
showToast(
|
|
||||||
t("Collections could not be loaded, please reload the app"),
|
|
||||||
{
|
|
||||||
type: "error",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setFetchError(error);
|
|
||||||
} finally {
|
|
||||||
setFetching(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, [collections, isFetching, showToast, fetchError, t]);
|
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ isCollectionDropping, isDraggingAnyCollection },
|
{ isCollectionDropping, isDraggingAnyCollection },
|
||||||
dropToReorderCollection,
|
dropToReorderCollection,
|
||||||
@@ -71,45 +44,59 @@ function Collections() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
{isDraggingAnyCollection && (
|
|
||||||
<DropCursor
|
|
||||||
isActiveDrop={isCollectionDropping}
|
|
||||||
innerRef={dropToReorderCollection}
|
|
||||||
position="top"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{orderedCollections.map((collection: Collection, index: number) => (
|
|
||||||
<DraggableCollectionLink
|
|
||||||
key={collection.id}
|
|
||||||
collection={collection}
|
|
||||||
activeDocument={documents.active}
|
|
||||||
prefetchDocument={documents.prefetchDocument}
|
|
||||||
belowCollection={orderedCollections[index + 1]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<SidebarAction action={createCollection} depth={0} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!collections.isLoaded || fetchError) {
|
|
||||||
return (
|
|
||||||
<Flex column>
|
|
||||||
<Header id="collections" title={t("Collections")}>
|
|
||||||
<PlaceholderCollections />
|
|
||||||
</Header>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex column>
|
<Flex column>
|
||||||
<Header id="collections" title={t("Collections")}>
|
<Header id="collections" title={t("Collections")}>
|
||||||
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
|
<Relative>
|
||||||
|
<PaginatedList
|
||||||
|
aria-label={t("Collections")}
|
||||||
|
items={collections.orderedData}
|
||||||
|
fetch={collections.fetchPage}
|
||||||
|
options={{ limit: 100 }}
|
||||||
|
loading={<PlaceholderCollections />}
|
||||||
|
heading={
|
||||||
|
isDraggingAnyCollection ? (
|
||||||
|
<DropCursor
|
||||||
|
isActiveDrop={isCollectionDropping}
|
||||||
|
innerRef={dropToReorderCollection}
|
||||||
|
position="top"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
empty={
|
||||||
|
<Empty type="tertiary" size="small">
|
||||||
|
{t("Empty")}
|
||||||
|
</Empty>
|
||||||
|
}
|
||||||
|
renderError={(props) => <StyledError {...props} />}
|
||||||
|
renderItem={(item: Collection, index) => (
|
||||||
|
<DraggableCollectionLink
|
||||||
|
key={item.id}
|
||||||
|
collection={item}
|
||||||
|
activeDocument={documents.active}
|
||||||
|
prefetchDocument={documents.prefetchDocument}
|
||||||
|
belowCollection={orderedCollections[index + 1]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarAction action={createCollection} depth={0} />
|
||||||
|
</PaginatedList>
|
||||||
|
</Relative>
|
||||||
</Header>
|
</Header>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Empty = styled(Text)`
|
||||||
|
margin-left: 36px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 34px;
|
||||||
|
font-style: italic;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledError = styled(Error)`
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 0 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
export default observer(Collections);
|
export default observer(Collections);
|
||||||
|
|||||||
@@ -148,6 +148,9 @@
|
|||||||
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
|
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
|
||||||
"Change Language": "Change Language",
|
"Change Language": "Change Language",
|
||||||
"Dismiss": "Dismiss",
|
"Dismiss": "Dismiss",
|
||||||
|
"You’re offline.": "You’re offline.",
|
||||||
|
"Sorry, an error occurred.": "Sorry, an error occurred.",
|
||||||
|
"Click to retry": "Click to retry",
|
||||||
"Back": "Back",
|
"Back": "Back",
|
||||||
"Documents": "Documents",
|
"Documents": "Documents",
|
||||||
"Results": "Results",
|
"Results": "Results",
|
||||||
@@ -157,10 +160,10 @@
|
|||||||
"Move document": "Move document",
|
"Move document": "Move document",
|
||||||
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
||||||
"Collections": "Collections",
|
"Collections": "Collections",
|
||||||
|
"Empty": "Empty",
|
||||||
"Untitled": "Untitled",
|
"Untitled": "Untitled",
|
||||||
"New nested document": "New nested document",
|
"New nested document": "New nested document",
|
||||||
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
"Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word",
|
||||||
"Empty": "Empty",
|
|
||||||
"Starred documents could not be loaded": "Starred documents could not be loaded",
|
"Starred documents could not be loaded": "Starred documents could not be loaded",
|
||||||
"Starred": "Starred",
|
"Starred": "Starred",
|
||||||
"Show more": "Show more",
|
"Show more": "Show more",
|
||||||
|
|||||||
Reference in New Issue
Block a user