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:
105
app/scenes/Settings/Export.tsx
Normal file
105
app/scenes/Settings/Export.tsx
Normal 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 it’s 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);
|
||||
154
app/scenes/Settings/Import.tsx
Normal file
154
app/scenes/Settings/Import.tsx
Normal 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);
|
||||
@@ -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 it’s 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);
|
||||
@@ -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]} • </>
|
||||
)}
|
||||
{fileOperation.error && <>{fileOperation.error} • </>}
|
||||
{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);
|
||||
|
||||
Reference in New Issue
Block a user