feat: Import improvements (#3064)

* feat: Split and simplify import/export pages in prep for more options

* minor fixes

* File operations for imports

* test

* icons
This commit is contained in:
Tom Moor
2022-02-06 22:29:24 -08:00
committed by GitHub
parent a4e9251eb7
commit d643c9453e
27 changed files with 621 additions and 454 deletions

View File

@@ -0,0 +1,105 @@
import { observer } from "mobx-react";
import { DownloadIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import FileOperation from "~/models/FileOperation";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import FileOperationListItem from "./components/FileOperationListItem";
function Export() {
const { t } = useTranslation();
const user = useCurrentUser();
const { fileOperations, collections } = useStores();
const { showToast } = useToasts();
const [isLoading, setLoading] = React.useState(false);
const [isExporting, setExporting] = React.useState(false);
const handleExport = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setLoading(true);
try {
await collections.export();
setExporting(true);
showToast(t("Export in progress…"));
} finally {
setLoading(false);
}
},
[t, collections, showToast]
);
const handleDelete = React.useCallback(
async (fileOperation: FileOperation) => {
try {
await fileOperations.delete(fileOperation);
showToast(t("Export deleted"));
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[fileOperations, showToast, t]
);
return (
<Scene title={t("Export")} icon={<DownloadIcon color="currentColor" />}>
<Heading>{t("Export")}</Heading>
<HelpText>
<Trans
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started we will email a link to <em>{{ userEmail }}</em> when its complete."
values={{
userEmail: user.email,
}}
components={{
em: <strong />,
}}
/>
</HelpText>
<Button
type="submit"
onClick={handleExport}
disabled={isLoading || isExporting}
primary
>
{isExporting
? t("Export Requested")
: isLoading
? `${t("Requesting Export")}`
: t("Export Data")}
</Button>
<br />
<PaginatedList
items={fileOperations.exports}
fetch={fileOperations.fetchPage}
options={{
type: "export",
}}
heading={
<Subheading>
<Trans>Recent exports</Trans>
</Subheading>
}
renderItem={(item) => (
<FileOperationListItem
key={item.id}
fileOperation={item}
handleDelete={handleDelete}
/>
)}
/>
</Scene>
);
}
export default observer(Export);

View File

@@ -0,0 +1,154 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import { NewDocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { cdnPath } from "@shared/utils/urls";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import Item from "~/components/List/Item";
import OutlineLogo from "~/components/OutlineLogo";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/uploadFile";
import FileOperationListItem from "./components/FileOperationListItem";
function Import() {
const { t } = useTranslation();
const fileRef = React.useRef<HTMLInputElement>(null);
const { collections, fileOperations } = useStores();
const { showToast } = useToasts();
const [isImporting, setImporting] = React.useState(false);
const handleFilePicked = React.useCallback(
async (ev) => {
const files = getDataTransferFiles(ev);
const file = files[0];
invariant(file, "File must exist to upload");
setImporting(true);
try {
const attachment = await uploadFile(file, {
name: file.name,
});
await collections.import(attachment.id);
showToast(
t("Your import is being processed, you can safely leave this page"),
{
type: "success",
timeout: 8000,
}
);
} catch (err) {
showToast(err.message);
} finally {
if (fileRef.current) {
fileRef.current.value = "";
}
setImporting(false);
}
},
[t, collections, showToast]
);
const handlePickFile = React.useCallback(
(ev) => {
ev.preventDefault();
if (fileRef.current) {
fileRef.current.click();
}
},
[fileRef]
);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon color="currentColor" />}>
<Heading>{t("Import")}</Heading>
<HelpText>
<Trans>
Quickly transfer your existing documents, pages, and files from other
tools and services into Outline. You can also drag and drop any HTML,
Markdown, and text documents directly into Collections in the app.
</Trans>
</HelpText>
<VisuallyHidden>
<input
type="file"
ref={fileRef}
onChange={handleFilePicked}
accept="application/zip"
/>
</VisuallyHidden>
<div>
<Item
border={false}
image={<OutlineLogo size={28} fill="currentColor" />}
title="Outline"
subtitle={t(
"Import a backup file that was previously exported from Outline"
)}
actions={
<Button
type="submit"
onClick={handlePickFile}
disabled={isImporting}
neutral
>
{isImporting ? `${t("Uploading")}` : t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" onClick={handlePickFile} disabled neutral>
{t("Coming soon")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import documents from Notion")}
actions={
<Button type="submit" onClick={handlePickFile} disabled neutral>
{t("Coming soon")}
</Button>
}
/>
</div>
<br />
<PaginatedList
items={fileOperations.imports}
fetch={fileOperations.fetchPage}
options={{
type: "import",
}}
heading={
<Subheading>
<Trans>Recent imports</Trans>
</Subheading>
}
renderItem={(item) => (
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
</Scene>
);
}
export default observer(Import);

View File

@@ -1,274 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import { CollectionIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import { parseOutlineExport, Item } from "@shared/utils/zip";
import FileOperation from "~/models/FileOperation";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import HelpText from "~/components/HelpText";
import Notice from "~/components/Notice";
import PaginatedList from "~/components/PaginatedList";
import Scene from "~/components/Scene";
import Subheading from "~/components/Subheading";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { uploadFile } from "~/utils/uploadFile";
import FileOperationListItem from "./components/FileOperationListItem";
function ImportExport() {
const { t } = useTranslation();
const user = useCurrentUser();
const fileRef = React.useRef<HTMLInputElement>(null);
const { fileOperations, collections } = useStores();
const { showToast } = useToasts();
const [isLoading, setLoading] = React.useState(false);
const [isImporting, setImporting] = React.useState(false);
const [isImported, setImported] = React.useState(false);
const [isExporting, setExporting] = React.useState(false);
const [file, setFile] = React.useState<File>();
const [importDetails, setImportDetails] = React.useState<
Item[] | undefined
>();
const handleImport = React.useCallback(async () => {
setImported(false);
setImporting(true);
try {
invariant(file, "File must exist to upload");
const attachment = await uploadFile(file, {
name: file.name,
});
await collections.import(attachment.id);
showToast(t("Import started"));
setImported(true);
} catch (err) {
showToast(err.message);
} finally {
if (fileRef.current) {
fileRef.current.value = "";
}
setImporting(false);
setFile(undefined);
setImportDetails(undefined);
}
}, [t, file, collections, showToast]);
const handleFilePicked = React.useCallback(async (ev) => {
ev.preventDefault();
const files = getDataTransferFiles(ev);
const file = files[0];
setFile(file);
try {
setImportDetails(await parseOutlineExport(file));
} catch (err) {
setImportDetails([]);
}
}, []);
const handlePickFile = React.useCallback(
(ev) => {
ev.preventDefault();
if (fileRef.current) {
fileRef.current.click();
}
},
[fileRef]
);
const handleExport = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setLoading(true);
try {
await collections.export();
setExporting(true);
showToast(t("Export in progress…"));
} finally {
setLoading(false);
}
},
[t, collections, showToast]
);
const handleDelete = React.useCallback(
async (fileOperation: FileOperation) => {
try {
await fileOperations.delete(fileOperation);
showToast(t("Export deleted"));
} catch (err) {
showToast(err.message, {
type: "error",
});
}
},
[fileOperations, showToast, t]
);
const hasCollections = importDetails
? !!importDetails.filter((detail) => detail.type === "collection").length
: false;
const hasDocuments = importDetails
? !!importDetails.filter((detail) => detail.type === "document").length
: false;
const isImportable = hasCollections && hasDocuments;
return (
<Scene
title={`${t("Import")} / ${t("Export")}`}
icon={<DocumentIcon color="currentColor" />}
>
<Heading>{t("Import")}</Heading>
<HelpText>
<Trans>
It is possible to import a zip file of folders and Markdown files
previously exported from an Outline instance. Support will soon be
added for importing from other services.
</Trans>
</HelpText>
<VisuallyHidden>
<input
type="file"
ref={fileRef}
onChange={handleFilePicked}
accept="application/zip"
/>
</VisuallyHidden>
{isImported && (
<Notice>
<Trans>
Your file has been uploaded and the import is currently being
processed, you can safely leave this page while it completes.
</Trans>
</Notice>
)}
{file && !isImportable && (
<ImportPreview>
<Trans
defaults="Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents."
values={{
fileName: file.name,
}}
components={{
em: <strong />,
}}
/>
</ImportPreview>
)}
{file && importDetails && isImportable ? (
<>
<ImportPreview as="div">
<Trans
defaults="<em>{{ fileName }}</em> looks good, the following collections and their documents will be imported:"
values={{
fileName: file.name,
}}
components={{
em: <strong />,
}}
/>
<List>
{importDetails
.filter((detail) => detail.type === "collection")
.map((detail) => (
<ImportPreviewItem key={detail.path}>
<CollectionIcon />
<CollectionName>{detail.name}</CollectionName>
</ImportPreviewItem>
))}
</List>
</ImportPreview>
<Button
type="submit"
onClick={handleImport}
disabled={isImporting}
primary
>
{isImporting ? `${t("Uploading")}` : t("Confirm & Import")}
</Button>
</>
) : (
<Button type="submit" onClick={handlePickFile} primary>
{t("Choose File")}
</Button>
)}
<Heading>{t("Export")}</Heading>
<HelpText>
<Trans
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started we will email a link to <em>{{ userEmail }}</em> when its complete."
values={{
userEmail: user.email,
}}
components={{
em: <strong />,
}}
/>
</HelpText>
<Button
type="submit"
onClick={handleExport}
disabled={isLoading || isExporting}
primary
>
{isExporting
? t("Export Requested")
: isLoading
? `${t("Requesting Export")}`
: t("Export Data")}
</Button>
<br />
<br />
<PaginatedList
items={fileOperations.orderedDataExports}
fetch={fileOperations.fetchPage}
options={{
type: "export",
}}
heading={
<Subheading>
<Trans>Recent exports</Trans>
</Subheading>
}
renderItem={(item) => (
<FileOperationListItem
key={item.id + item.state}
fileOperation={item}
handleDelete={handleDelete}
/>
)}
/>
</Scene>
);
}
const List = styled.ul`
padding: 0;
margin: 8px 0 0;
`;
const ImportPreview = styled(Notice)`
margin-bottom: 16px;
`;
const ImportPreviewItem = styled.li`
display: flex;
align-items: center;
list-style: none;
`;
const CollectionName = styled.span`
font-weight: 500;
margin-left: 4px;
`;
export default observer(ImportExport);

View File

@@ -1,38 +1,54 @@
import { observer } from "mobx-react";
import { DoneIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import FileOperationMenu from "~/menus/FileOperationMenu";
type Props = {
fileOperation: FileOperation;
handleDelete: (arg0: FileOperation) => Promise<void>;
handleDelete?: (arg0: FileOperation) => Promise<void>;
};
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const stateMapping = {
creating: t("Processing"),
expired: t("Expired"),
uploading: t("Processing"),
error: t("Error"),
error: t("Failed"),
};
const iconMapping = {
creating: <Spinner />,
uploading: <Spinner />,
complete: <DoneIcon color={theme.primary} />,
error: <WarningIcon color={theme.danger} />,
};
const title =
fileOperation.type === "import" || fileOperation.collectionId
? fileOperation.name
: t("All collections");
return (
<ListItem
title={
fileOperation.collection
? fileOperation.collection.name
: t("All collections")
}
title={title}
image={iconMapping[fileOperation.state]}
subtitle={
<>
{fileOperation.state !== "complete" && (
<>{stateMapping[fileOperation.state]}&nbsp;&nbsp;</>
)}
{fileOperation.error && <>{fileOperation.error}&nbsp;&nbsp;</>}
{t(`{{userName}} requested`, {
userName:
user.id === fileOperation.user.id
@@ -45,7 +61,7 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
</>
}
actions={
fileOperation.state === "complete" ? (
fileOperation.state === "complete" && handleDelete ? (
<Action>
<FileOperationMenu
id={fileOperation.id}
@@ -61,4 +77,4 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
);
};
export default FileOperationListItem;
export default observer(FileOperationListItem);