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:
Tom Moor
2022-07-16 00:11:04 +01:00
committed by GitHub
parent acabc00643
commit a16cf72b73
7 changed files with 148 additions and 93 deletions

View File

@@ -1,17 +1,6 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
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`
const ButtonLink = styled.button`
margin: 0;
padding: 0;
border: 0;

View File

@@ -2,6 +2,7 @@ import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
user-select: none;
`;
export default Empty;

View 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("Youre 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;
}
}
`;

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
type Props = {
@@ -40,6 +41,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
<DocumentListItem
key={item.id}

View File

@@ -34,12 +34,19 @@ type Props<T> = WithTranslation &
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
error?: Error;
@observable
isFetchingMore = false;
@@ -80,6 +87,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetchingMore = false;
};
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
@@ -87,25 +95,30 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
this.error = undefined;
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
try {
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
this.renderCount += limit;
} catch (err) {
this.error = err;
} finally {
// only the most recent fetch should end the loading state
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,
empty = null,
renderHeading,
renderError,
onEscape,
children,
} = this.props;
const showLoading =
@@ -157,6 +172,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
@@ -205,6 +224,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
});
}}
</ArrowKeyNavigation>
{children}
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}

View File

@@ -3,12 +3,14 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Collection from "~/models/Collection";
import Fade from "~/components/Fade";
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 useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import DraggableCollectionLink from "./DraggableCollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
@@ -18,39 +20,10 @@ import SidebarAction from "./SidebarAction";
import { DragObject } from "./SidebarLink";
function Collections() {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded = !!collections.orderedData.length;
const { t } = useTranslation();
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 [
{ isCollectionDropping, isDraggingAnyCollection },
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 (
<Flex column>
<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>
</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);

View File

@@ -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?",
"Change Language": "Change Language",
"Dismiss": "Dismiss",
"Youre offline.": "Youre offline.",
"Sorry, an error occurred.": "Sorry, an error occurred.",
"Click to retry": "Click to retry",
"Back": "Back",
"Documents": "Documents",
"Results": "Results",
@@ -157,10 +160,10 @@
"Move document": "Move document",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"Collections": "Collections",
"Empty": "Empty",
"Untitled": "Untitled",
"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",
"Empty": "Empty",
"Starred documents could not be loaded": "Starred documents could not be loaded",
"Starred": "Starred",
"Show more": "Show more",