feat: space export (#506)

* wip

* Space export
* option to export pages with children
* include attachments in exports
* unified export UI

* cleanup

* fix: change export icon

* add export button to space settings

* cleanups

* export name
This commit is contained in:
Philip Okugbe
2024-11-30 19:47:22 +00:00
committed by GitHub
parent 9fa432dba9
commit fe83557767
20 changed files with 926 additions and 117 deletions

View File

@ -0,0 +1,149 @@
import {
Modal,
Button,
Group,
Text,
Select,
Switch,
Divider,
} from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import { ExportFormat } from "@/features/page/types/page.types.ts";
import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
interface ExportModalProps {
id: string;
type: "space" | "page";
open: boolean;
onClose: () => void;
}
export default function ExportModal({
id,
type,
open,
onClose,
}: ExportModalProps) {
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
const [includeChildren, setIncludeChildren] = useState<boolean>(false);
const [includeAttachments, setIncludeAttachments] = useState<boolean>(true);
const handleExport = async () => {
try {
if (type === "page") {
await exportPage({ pageId: id, format, includeChildren });
}
if (type === "space") {
await exportSpace({ spaceId: id, format, includeAttachments });
}
setIncludeChildren(false);
setIncludeAttachments(true);
onClose();
} catch (err) {
notifications.show({
message: "Export failed:" + err.response?.data.message,
color: "red",
});
console.error("export error", err);
}
};
const handleChange = (format: ExportFormat) => {
setFormat(format);
};
return (
<Modal.Root
opened={open}
onClose={onClose}
size={500}
padding="xl"
yOffset="10vh"
xOffset={0}
mah={400}
>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header py={0}>
<Modal.Title fw={500}>Export {type}</Modal.Title>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
{type === "page" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch
onChange={(event) =>
setIncludeChildren(event.currentTarget.checked)
}
checked={includeChildren}
/>
</Group>
</>
)}
{type === "space" && (
<>
<Divider my="sm" />
<Group justify="space-between" wrap="nowrap">
<div>
<Text size="md">Include attachments</Text>
</div>
<Switch
onChange={(event) =>
setIncludeAttachments(event.currentTarget.checked)
}
checked={includeAttachments}
/>
</Group>
</>
)}
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel
</Button>
<Button onClick={handleExport}>Export</Button>
</Group>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
interface ExportFormatSelection {
format: ExportFormat;
onChange: (value: string) => void;
}
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
return (
<Select
data={[
{ value: "markdown", label: "Markdown" },
{ value: "html", label: "HTML" },
]}
defaultValue={format}
onChange={onChange}
styles={{ wrapper: { maxWidth: 120 } }}
comboboxProps={{ width: "120" }}
allowDeselect={false}
withCheckIcon={false}
aria-label="Select export format"
/>
);
}

View File

@ -2,7 +2,7 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
import {
IconArrowsHorizontal,
IconDots,
IconDownload,
IconFileExport,
IconHistory,
IconLink,
IconMessage,
@ -24,6 +24,7 @@ import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface PageHeaderMenuProps {
readOnly?: boolean;
@ -126,7 +127,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
<Menu.Divider />
<Menu.Item
leftSection={<IconDownload size={16} />}
leftSection={<IconFileExport size={16} />}
onClick={openExportModal}
>
Export
@ -154,8 +155,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
</Menu.Dropdown>
</Menu>
<PageExportModal
pageId={page.id}
<ExportModal
type="page"
id={page.id}
open={exportOpened}
onClose={closeExportModal}
/>

View File

@ -1,4 +1,4 @@
import { Modal, Button, Group, Text, Select } from "@mantine/core";
import { Modal, Button, Group, Text, Select, Switch } from "@mantine/core";
import { exportPage } from "@/features/page/services/page-service.ts";
import { useState } from "react";
import * as React from "react";
@ -57,8 +57,18 @@ export default function PageExportModal({
<Text size="md">Format</Text>
</div>
<ExportFormatSelection format={format} onChange={handleChange} />
</Group>
<Group justify="space-between" wrap="nowrap" pt="md">
<div>
<Text size="md">Include subpages</Text>
</div>
<Switch defaultChecked />
</Group>
<Group justify="center" mt="md">
<Button onClick={onClose} variant="default">
Cancel

View File

@ -15,7 +15,7 @@ import {
IconChevronDown,
IconChevronRight,
IconDotsVertical,
IconFileDescription,
IconFileDescription, IconFileExport,
IconLink,
IconPlus,
IconPointFilled,
@ -39,7 +39,12 @@ import {
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useClipboard, useElementSize, useMergedRef } from "@mantine/hooks";
import {
useClipboard,
useDisclosure,
useElementSize,
useMergedRef,
} from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@ -47,6 +52,7 @@ import { notifications } from "@mantine/notifications";
import { getAppUrl } from "@/lib/config.ts";
import { extractPageSlugId } from "@/lib";
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
import ExportModal from "@/components/common/export-modal";
interface SpaceTreeProps {
spaceId: string;
@ -402,6 +408,8 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { openDeleteModal } = useDeletePageModal();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const handleCopyLink = () => {
const pageUrl =
@ -411,56 +419,76 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
};
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
variant="transparent"
c="gray"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
Copy link
</Menu.Item>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
Copy link
</Menu.Item>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
Export page
</Menu.Item>
<Menu.Item
c="red"
leftSection={
<IconTrash style={{ width: rem(14), height: rem(14) }} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
{!(treeApi.props.disableEdit as boolean) && (
<>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={
<IconTrash size={16} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openDeleteModal({ onConfirm: () => treeApi?.delete(node) });
}}
>
Delete
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}

View File

@ -48,6 +48,7 @@ export interface IPageInput {
export interface IExportPageParams {
pageId: string;
format: ExportFormat;
includeChildren?: boolean;
}
export enum ExportFormat {

View File

@ -5,36 +5,38 @@ import {
Text,
Tooltip,
UnstyledButton,
} from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
import {
IconArrowDown,
IconDots,
IconFileExport,
IconHome,
IconPlus,
IconSearch,
IconSettings,
} from '@tabler/icons-react';
} from "@tabler/icons-react";
import classes from './space-sidebar.module.css';
import React, { useMemo } from 'react';
import { useAtom } from 'jotai';
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from 'clsx';
import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
import { getSpaceUrl } from '@/lib/config.ts';
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { getSpaceUrl } from "@/lib/config.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '@/features/space/permissions/permissions.type.ts';
import PageImportModal from '@/features/page/components/page-import-modal.tsx';
import { SwitchSpace } from './switch-space';
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
import { SwitchSpace } from "./switch-space";
import ExportModal from "@/components/common/export-modal";
export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom);
@ -52,7 +54,7 @@ export function SpaceSidebar() {
}
function handleCreatePage() {
tree?.create({ parentId: null, type: 'internal', index: 0 });
tree?.create({ parentId: null, type: "internal", index: 0 });
}
return (
@ -61,7 +63,7 @@ export function SpaceSidebar() {
<div
className={classes.section}
style={{
border: 'none',
border: "none",
marginTop: 2,
marginBottom: 3,
}}
@ -78,7 +80,7 @@ export function SpaceSidebar() {
classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton
: ''
: ""
)}
>
<div className={classes.menuItemInner}>
@ -191,6 +193,8 @@ interface SpaceMenuProps {
function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
const [importOpened, { open: openImportModal, close: closeImportModal }] =
useDisclosure(false);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return (
<>
@ -215,6 +219,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
Import pages
</Menu.Item>
<Menu.Item
onClick={openExportModal}
leftSection={<IconFileExport size={16} />}
>
Export space
</Menu.Item>
<Menu.Divider />
<Menu.Item
@ -231,6 +242,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
open={importOpened}
onClose={closeImportModal}
/>
<ExportModal
type="space"
id={spaceId}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}

View File

@ -1,8 +1,10 @@
import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Divider, Group, Text } from '@mantine/core';
import { Button, Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
import { useDisclosure } from "@mantine/hooks";
import ExportModal from "@/components/common/export-modal.tsx";
interface SpaceDetailsProps {
spaceId: string;
@ -10,6 +12,8 @@ interface SpaceDetailsProps {
}
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { data: space, isLoading } = useSpaceQuery(spaceId);
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
return (
<>
@ -22,6 +26,22 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Export space</Text>
<Text size="sm" c="dimmed">
Export all pages and attachments in this space
</Text>
</div>
<Button onClick={openExportModal}>
Export
</Button>
</Group>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
@ -34,6 +54,13 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<DeleteSpaceModal space={space} />
</Group>
<ExportModal
type="space"
id={space.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
)}
</div>

View File

@ -1,56 +1,72 @@
import api from '@/lib/api-client';
import api from "@/lib/api-client";
import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IExportSpaceParams,
IRemoveSpaceMember,
ISpace,
} from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver";
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
export async function getSpaces(
params?: QueryParams
): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params);
return req.data;
}
export async function getSpaceById(spaceId: string): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/info', { spaceId });
const req = await api.post<ISpace>("/spaces/info", { spaceId });
return req.data;
}
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/create', data);
const req = await api.post<ISpace>("/spaces/create", data);
return req.data;
}
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>('/spaces/update', data);
const req = await api.post<ISpace>("/spaces/update", data);
return req.data;
}
export async function deleteSpace(spaceId: string): Promise<void> {
await api.post<void>('/spaces/delete', { spaceId });
await api.post<void>("/spaces/delete", { spaceId });
}
export async function getSpaceMembers(
spaceId: string
): Promise<IPagination<IUser>> {
const req = await api.post<any>('/spaces/members', { spaceId });
const req = await api.post<any>("/spaces/members", { spaceId });
return req.data;
}
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
await api.post('/spaces/members/add', data);
await api.post("/spaces/members/add", data);
}
export async function removeSpaceMember(
data: IRemoveSpaceMember
): Promise<void> {
await api.post('/spaces/members/remove', data);
await api.post("/spaces/members/remove", data);
}
export async function changeMemberRole(
data: IChangeSpaceMemberRole
): Promise<void> {
await api.post('/spaces/members/change-role', data);
await api.post("/spaces/members/change-role", data);
}
export async function exportSpace(data: IExportSpaceParams): Promise<void> {
const req = await api.post("/spaces/export", data, {
responseType: "blob",
});
const fileName = req?.headers["content-disposition"]
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, decodeURIComponent(fileName));
}

View File

@ -3,6 +3,7 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import { ExportFormat } from "@/features/page/types/page.types.ts";
export interface ISpace {
id: string;
@ -68,3 +69,9 @@ export interface SpaceGroupInfo {
}
export type ISpaceMember = { role: string } & (SpaceUserInfo | SpaceGroupInfo);
export interface IExportSpaceParams {
spaceId: string;
format: ExportFormat;
includeAttachments?: boolean;
}

View File

@ -26,14 +26,18 @@ api.interceptors.request.use(
},
(error) => {
return Promise.reject(error);
},
}
);
api.interceptors.response.use(
(response) => {
// we need the response headers
if (response.request.responseURL.includes("/api/pages/export")) {
return response;
// we need the response headers for these endpoints
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
if (response.request.responseURL) {
const path = new URL(response.request.responseURL)?.pathname;
if (path && exemptEndpoints.includes(path)) {
return response;
}
}
return response.data;
@ -72,7 +76,7 @@ api.interceptors.response.use(
}
}
return Promise.reject(error);
},
}
);
function redirectToLogin() {