mirror of
https://github.com/outline/outline.git
synced 2025-12-19 09:39:39 -06:00
Archive collections (#7266)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
SearchIcon,
|
||||
ShapesIcon,
|
||||
StarredIcon,
|
||||
@@ -10,11 +12,13 @@ import {
|
||||
UnstarredIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Collection from "~/models/Collection";
|
||||
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
|
||||
import { CollectionNew } from "~/components/Collection/CollectionNew";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
@@ -129,9 +133,20 @@ export const searchInCollection = createAction({
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) =>
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).readDocument,
|
||||
visible: ({ activeCollectionId }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
if (!collection?.isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
@@ -190,6 +205,72 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).archive;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
const { dialogs, collections } = stores;
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogs.openModal({
|
||||
title: t("Archive collection"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await collection.archive();
|
||||
toast.success(t("Collection archived"));
|
||||
}}
|
||||
submitText={t("Archive")}
|
||||
savingText={`${t("Archiving")}…`}
|
||||
>
|
||||
{t(
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeCollectionId).restore;
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
await collection.restore();
|
||||
toast.success(t("Collection restored"));
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
|
||||
45
app/components/CollectionBreadcrumb.tsx
Normal file
45
app/components/CollectionBreadcrumb.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
|
||||
const category: MenuInternalLink | undefined = collection.isArchived
|
||||
? {
|
||||
type: "route",
|
||||
icon: <ArchiveIcon />,
|
||||
title: t("Archive"),
|
||||
to: archivePath(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const output = [];
|
||||
if (category) {
|
||||
output.push(category);
|
||||
}
|
||||
|
||||
output.push(collectionNode);
|
||||
|
||||
return output;
|
||||
}, [collection, t]);
|
||||
|
||||
return <Breadcrumb items={items} highlightFirstItem />;
|
||||
};
|
||||
@@ -133,16 +133,16 @@ function AppSidebar() {
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section auto>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && (
|
||||
<>
|
||||
<ArchiveLink />
|
||||
<TrashLink />
|
||||
</>
|
||||
)}
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
|
||||
@@ -1,41 +1,101 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import Flex from "@shared/components/Flex";
|
||||
import Collection from "~/models/Collection";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { archivePath } from "~/utils/routeHelpers";
|
||||
import SidebarLink, { DragObject } from "./SidebarLink";
|
||||
import { useDropToArchive } from "../hooks/useDragAndDrop";
|
||||
import { ArchivedCollectionLink } from "./ArchivedCollectionLink";
|
||||
import { StyledError } from "./Collections";
|
||||
import PlaceholderCollections from "./PlaceholderCollections";
|
||||
import Relative from "./Relative";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
|
||||
function ArchiveLink() {
|
||||
const { policies, documents } = useStores();
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject) => {
|
||||
const document = documents.get(item.id);
|
||||
await document?.archive();
|
||||
toast.success(t("Document archived"));
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isDocumentDropping: monitor.isOver(),
|
||||
}),
|
||||
});
|
||||
const [disclosure, setDisclosure] = React.useState<boolean>(false);
|
||||
const [expanded, setExpanded] = React.useState<boolean | undefined>();
|
||||
|
||||
const { request, data, loading, error } = useRequest(
|
||||
collections.fetchArchived,
|
||||
true
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUndefined(data) && !loading && isUndefined(error)) {
|
||||
setDisclosure(data.length > 0);
|
||||
}
|
||||
}, [data, loading, error]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDisclosure(collections.archived.length > 0);
|
||||
}, [collections.archived]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (disclosure && isUndefined(expanded)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [disclosure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expanded) {
|
||||
void request();
|
||||
}
|
||||
}, [expanded, request]);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const [{ isOverArchiveSection, isDragging }, dropToArchiveRef] =
|
||||
useDropToArchive();
|
||||
|
||||
return (
|
||||
<div ref={dropToArchiveDocument}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isDocumentDropping} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={documents.active?.isArchived && !documents.active?.isDeleted}
|
||||
isActiveDrop={isDocumentDropping}
|
||||
/>
|
||||
</div>
|
||||
<Flex column>
|
||||
<div ref={dropToArchiveRef}>
|
||||
<SidebarLink
|
||||
to={archivePath()}
|
||||
icon={<ArchiveIcon open={isOverArchiveSection && isDragging} />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
isActiveDrop={isOverArchiveSection && isDragging}
|
||||
depth={0}
|
||||
expanded={disclosure ? expanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{expanded === true ? (
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Archived collections")}
|
||||
items={collections.archived}
|
||||
loading={<PlaceholderCollections />}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection) => (
|
||||
<ArchivedCollectionLink
|
||||
key={item.id}
|
||||
depth={1}
|
||||
collection={item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Relative>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
47
app/components/Sidebar/components/ArchivedCollectionLink.tsx
Normal file
47
app/components/Sidebar/components/ArchivedCollectionLink.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CollectionLink from "./CollectionLink";
|
||||
import CollectionLinkChildren from "./CollectionLinkChildren";
|
||||
import Relative from "./Relative";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export function ArchivedCollectionLink({ collection, depth }: Props) {
|
||||
const { documents } = useStores();
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const handleDisclosureClick = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded((e) => !e);
|
||||
}, []);
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionLink
|
||||
depth={depth ? depth : 0}
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
activeDocument={documents.active}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
<Relative>
|
||||
<CollectionLinkChildren
|
||||
collection={collection}
|
||||
expanded={expanded}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
/>
|
||||
</Relative>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ type Props = {
|
||||
onDisclosureClick: (ev?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
activeDocument: Document | undefined;
|
||||
isDraggingAnyCollection?: boolean;
|
||||
depth?: number;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const CollectionLink: React.FC<Props> = ({
|
||||
@@ -37,6 +39,8 @@ const CollectionLink: React.FC<Props> = ({
|
||||
expanded,
|
||||
onDisclosureClick,
|
||||
isDraggingAnyCollection,
|
||||
depth,
|
||||
onClick,
|
||||
}: Props) => {
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
@@ -115,6 +119,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
<Relative ref={drop}>
|
||||
<DropToImport collectionId={collection.id}>
|
||||
<SidebarLink
|
||||
onClick={onClick}
|
||||
to={{
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
@@ -140,7 +145,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
depth={0}
|
||||
depth={depth ? depth : 0}
|
||||
menu={
|
||||
!isEditing &&
|
||||
!isDraggingAnyCollection && (
|
||||
|
||||
@@ -55,7 +55,7 @@ function Collections() {
|
||||
<PaginatedList
|
||||
options={params}
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
items={collections.allActive}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
@@ -84,7 +84,7 @@ function Collections() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
export const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
@@ -149,6 +149,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
@@ -245,6 +246,7 @@ export function useDropToReparentDocument(
|
||||
!!pathToNode &&
|
||||
!pathToNode.includes(monitor.getItem().id) &&
|
||||
item.id !== node.id &&
|
||||
!!document?.isActive &&
|
||||
policies.abilities(node.id).update &&
|
||||
policies.abilities(item.id).move,
|
||||
hover: (_item, monitor) => {
|
||||
@@ -297,6 +299,8 @@ export function useDropToReorderDocument(
|
||||
const { t } = useTranslation();
|
||||
const { documents, collections, dialogs, policies } = useStores();
|
||||
|
||||
const document = documents.get(node.id);
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
@@ -304,7 +308,11 @@ export function useDropToReorderDocument(
|
||||
>({
|
||||
accept: "document",
|
||||
canDrop: (item: DragObject) => {
|
||||
if (item.id === node.id || !policies.abilities(item.id)?.move) {
|
||||
if (
|
||||
item.id === node.id ||
|
||||
!policies.abilities(item.id)?.move ||
|
||||
!document?.isActive
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -427,3 +435,44 @@ export function useDropToReorderUserMembership(getIndex?: () => string) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for shared logic that allows dropping documents and collections onto archive section
|
||||
*/
|
||||
export function useDropToArchive() {
|
||||
const accept = ["document", "collection"];
|
||||
const { documents, collections, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useDrop<
|
||||
DragObject,
|
||||
Promise<void>,
|
||||
{ isOverArchiveSection: boolean; isDragging: boolean }
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
if (type === "collection") {
|
||||
model = collections.get(item.id);
|
||||
} else {
|
||||
model = documents.get(item.id);
|
||||
}
|
||||
|
||||
if (model) {
|
||||
await model.archive();
|
||||
toast.success(
|
||||
type === "collection"
|
||||
? t("Collection archived")
|
||||
: t("Document archived")
|
||||
);
|
||||
}
|
||||
},
|
||||
canDrop: (item) => policies.abilities(item.id).archive,
|
||||
collect: (monitor) => ({
|
||||
isOverArchiveSection: !!monitor.isOver(),
|
||||
isDragging: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -407,6 +407,48 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.archive",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
|
||||
documents.unarchivedInCollection(collectionId).forEach(
|
||||
action((doc) => {
|
||||
if (!doc.publishedAt) {
|
||||
// draft is to be detached from collection, not archived
|
||||
doc.collectionId = null;
|
||||
} else {
|
||||
doc.archivedAt = event.archivedAt as string;
|
||||
}
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.restore",
|
||||
async (event: PartialExcept<Collection, "id">) => {
|
||||
const collectionId = event.id;
|
||||
documents
|
||||
.archivedInCollection(collectionId, {
|
||||
archivedAt: event.archivedAt as string,
|
||||
})
|
||||
.forEach(
|
||||
action((doc) => {
|
||||
doc.archivedAt = null;
|
||||
policies.remove(doc.id);
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch collection to update policies
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialExcept<Team, "id">) => {
|
||||
if ("sharing" in event && event.sharing !== auth.team?.sharing) {
|
||||
documents.all.forEach((document) => {
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
createTemplate,
|
||||
archiveCollection,
|
||||
restoreCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -151,6 +153,7 @@ function CollectionMenu({
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
actionToMenuItem(restoreCollection, context),
|
||||
actionToMenuItem(starCollection, context),
|
||||
actionToMenuItem(unstarCollection, context),
|
||||
{
|
||||
@@ -224,6 +227,7 @@ function CollectionMenu({
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
actionToMenuItem(archiveCollection, context),
|
||||
actionToMenuItem(searchInCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
|
||||
@@ -215,8 +215,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) && can.restore) ||
|
||||
!!can.unarchive,
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive),
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
@@ -224,9 +224,8 @@ const MenuContent: React.FC<MenuContentProps> = ({
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive) &&
|
||||
restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
|
||||
@@ -80,6 +80,18 @@ export default class Collection extends ParanoidModel {
|
||||
@observable
|
||||
urlId: string;
|
||||
|
||||
/**
|
||||
* The date and time the collection was archived.
|
||||
*/
|
||||
@observable
|
||||
archivedAt: string;
|
||||
|
||||
/**
|
||||
* User who archived the collection.
|
||||
*/
|
||||
@observable
|
||||
archivedBy?: User;
|
||||
|
||||
/** Returns whether the collection is empty, or undefined if not loaded. */
|
||||
@computed
|
||||
get isEmpty(): boolean | undefined {
|
||||
@@ -154,6 +166,21 @@ export default class Collection extends ParanoidModel {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isArchived() {
|
||||
return !!this.archivedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isDeleted() {
|
||||
return !!this.deletedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isActive() {
|
||||
return !this.isArchived && !this.isDeleted;
|
||||
}
|
||||
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching) {
|
||||
return;
|
||||
@@ -314,6 +341,10 @@ export default class Collection extends ParanoidModel {
|
||||
@action
|
||||
unstar = async () => this.store.unstar(this);
|
||||
|
||||
archive = () => this.store.archive(this);
|
||||
|
||||
restore = () => this.store.restore(this);
|
||||
|
||||
export = (format: FileOperationFormat, includeAttachments: boolean) =>
|
||||
client.post("/collections.export", {
|
||||
id: this.id,
|
||||
|
||||
@@ -3,5 +3,5 @@ import ParanoidModel from "./ParanoidModel";
|
||||
|
||||
export default abstract class ArchivableModel extends ParanoidModel {
|
||||
@observable
|
||||
archivedAt: string | undefined;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
|
||||
29
app/scenes/Collection/components/Notices.tsx
Normal file
29
app/scenes/Collection/components/Notices.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ArchiveIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import Notice from "~/components/Notice";
|
||||
import Time from "~/components/Time";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
export default function Notices({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
{collection.isArchived && !collection.isDeleted && (
|
||||
<Notice icon={<ArchiveIcon />}>
|
||||
{t("Archived by {{userName}}", {
|
||||
userName: collection.archivedBy?.name ?? t("Unknown"),
|
||||
})}
|
||||
|
||||
<Time dateTime={collection.archivedAt} addSuffix />
|
||||
</Notice>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import { StatusFilter } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import Collection from "~/models/Collection";
|
||||
import Search from "~/scenes/Search";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import Icon, { IconTitleWrapper } from "~/components/Icon";
|
||||
@@ -28,6 +30,7 @@ import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import PinnedDocuments from "~/components/PinnedDocuments";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import { editCollection } from "~/actions/definitions/collections";
|
||||
@@ -41,6 +44,7 @@ import Actions from "./components/Actions";
|
||||
import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -132,7 +136,9 @@ function CollectionScene() {
|
||||
centered={false}
|
||||
textTitle={collection.name}
|
||||
left={
|
||||
collection.isEmpty ? undefined : (
|
||||
collection.isArchived ? (
|
||||
<CollectionBreadcrumb collection={collection} />
|
||||
) : collection.isEmpty ? undefined : (
|
||||
<InputSearchPage
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
@@ -163,6 +169,7 @@ function CollectionScene() {
|
||||
collectionId={collection.id}
|
||||
>
|
||||
<CenteredContent withStickyHeader>
|
||||
<Notices collection={collection} />
|
||||
<CollectionHeading>
|
||||
<IconTitleWrapper>
|
||||
{can.update ? (
|
||||
@@ -192,26 +199,28 @@ function CollectionScene() {
|
||||
<CollectionDescription collection={collection} />
|
||||
|
||||
<Documents>
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{!collection.isArchived && (
|
||||
<Tabs>
|
||||
<Tab to={collectionPath(collection.path)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionPath(collection.path, "alphabetical")} exact>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
)}
|
||||
{collection.isEmpty ? (
|
||||
<Empty collection={collection} />
|
||||
) : (
|
||||
) : !collection.isArchived ? (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
@@ -279,6 +288,24 @@ function CollectionScene() {
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path={collectionPath(collection.path)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.archivedInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
heading={<Subheading sticky>{t("Documents")}</Subheading>}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
statusFilter: [StatusFilter.Archived],
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</Documents>
|
||||
</CenteredContent>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import invariant from "invariant";
|
||||
import find from "lodash/find";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { computed, action } from "mobx";
|
||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||
import { computed, action, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { Properties } from "~/types";
|
||||
import { PaginationParams, Properties } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
@@ -27,6 +32,11 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@computed
|
||||
get allActive() {
|
||||
return this.orderedData.filter((c) => c.isActive);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): Collection[] {
|
||||
let collections = Array.from(this.data.values());
|
||||
@@ -97,6 +107,30 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
archive = async (collection: Collection) => {
|
||||
const res = await client.post("/collections.archive", {
|
||||
id: collection.id,
|
||||
});
|
||||
runInAction("Collection#archive", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.add(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
|
||||
@action
|
||||
restore = async (collection: Collection) => {
|
||||
const res = await client.post("/collections.restore", {
|
||||
id: collection.id,
|
||||
});
|
||||
runInAction("Collection#restore", () => {
|
||||
invariant(res?.data, "Data should be available");
|
||||
this.add(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
};
|
||||
|
||||
async update(params: Properties<Collection>): Promise<Collection> {
|
||||
const result = await super.update(params);
|
||||
|
||||
@@ -119,6 +153,52 @@ export default class CollectionsStore extends Store<Collection> {
|
||||
return model;
|
||||
}
|
||||
|
||||
@action
|
||||
fetchNamedPage = async (
|
||||
request = "list",
|
||||
options:
|
||||
| (PaginationParams & { statusFilter: CollectionStatusFilter[] })
|
||||
| undefined
|
||||
): Promise<Collection[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.${request}`, options);
|
||||
invariant(res?.data, "Collection list not available");
|
||||
runInAction("CollectionsStore#fetchNamedPage", () => {
|
||||
res.data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
this.isLoaded = true;
|
||||
});
|
||||
return res.data;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Collection[]> =>
|
||||
this.fetchNamedPage("list", {
|
||||
...options,
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
});
|
||||
|
||||
@computed
|
||||
get archived(): Collection[] {
|
||||
return orderBy(this.orderedData, "archivedAt", "desc").filter(
|
||||
(c) => c.isArchived && !c.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get publicCollections() {
|
||||
return this.orderedData.filter(
|
||||
(collection) =>
|
||||
collection.permission &&
|
||||
Object.values(CollectionPermission).includes(collection.permission)
|
||||
);
|
||||
}
|
||||
|
||||
star = async (collection: Collection, index?: string) => {
|
||||
await this.rootStore.stars.create({
|
||||
collectionId: collection.id,
|
||||
|
||||
@@ -121,6 +121,33 @@ export default class DocumentsStore extends Store<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
archivedInCollection(
|
||||
collectionId: string,
|
||||
options?: { archivedAt: string }
|
||||
): Document[] {
|
||||
const filterCond = (document: Document) =>
|
||||
options
|
||||
? document.collectionId === collectionId &&
|
||||
document.isArchived &&
|
||||
document.archivedAt === options.archivedAt &&
|
||||
!document.isDeleted
|
||||
: document.collectionId === collectionId &&
|
||||
document.isArchived &&
|
||||
!document.isDeleted;
|
||||
|
||||
return filter(this.orderedData, filterCond);
|
||||
}
|
||||
|
||||
unarchivedInCollection(collectionId: string): Document[] {
|
||||
return filter(
|
||||
this.orderedData,
|
||||
(document) =>
|
||||
document.collectionId === collectionId &&
|
||||
!document.isArchived &&
|
||||
!document.isDeleted
|
||||
);
|
||||
}
|
||||
|
||||
templatesInCollection(collectionId: string): Document[] {
|
||||
return orderBy(
|
||||
filter(
|
||||
@@ -313,8 +340,18 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
this.fetchNamedPage("archived", options);
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
const archivedInResponse = await this.fetchNamedPage("archived", options);
|
||||
const archivedInMemory = this.archived;
|
||||
|
||||
archivedInMemory.forEach((docInMemory) => {
|
||||
!archivedInResponse.find(
|
||||
(docInResponse) => docInResponse.id === docInMemory.id
|
||||
) && this.remove(docInMemory.id);
|
||||
});
|
||||
|
||||
return archivedInResponse;
|
||||
};
|
||||
|
||||
@action
|
||||
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> =>
|
||||
|
||||
@@ -161,6 +161,8 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "collections.delete":
|
||||
case "collections.move":
|
||||
case "collections.permission_changed":
|
||||
case "collections.archive":
|
||||
case "collections.restore":
|
||||
await this.handleCollectionEvent(subscription, event);
|
||||
return;
|
||||
case "collections.add_user":
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.addColumn("collections", "archivedAt", {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true,
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.addIndex("collections", ["archivedAt"], {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.removeIndex("collections", ["archivedAt"], {
|
||||
transaction,
|
||||
});
|
||||
await queryInterface.removeColumn("collections", "archivedAt", {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("collections", "archivedById", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "users",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.removeColumn("collections", "archivedById");
|
||||
},
|
||||
};
|
||||
@@ -175,6 +175,67 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].children.length).toBe(2);
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
test("should add the document along with its nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
title: "New doc",
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
|
||||
// create a nested doc within New doc
|
||||
const nestedDocument = await buildDocument({
|
||||
title: "Nested doc",
|
||||
parentDocumentId: document.id,
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
|
||||
expect(collection.documentStructure).toBeNull();
|
||||
|
||||
await collection.addDocumentToStructure(document);
|
||||
|
||||
expect(collection.documentStructure).not.toBeNull();
|
||||
expect(collection.documentStructure).toHaveLength(1);
|
||||
expect(collection.documentStructure![0].id).toBe(document.id);
|
||||
expect(collection.documentStructure![0].children).toHaveLength(1);
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(
|
||||
nestedDocument.id
|
||||
);
|
||||
});
|
||||
|
||||
test("should add the document along with its archived nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
title: "New doc",
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
|
||||
// create a nested doc within New doc
|
||||
const nestedDocument = await buildDocument({
|
||||
title: "Nested doc",
|
||||
parentDocumentId: document.id,
|
||||
teamId: collection.teamId,
|
||||
});
|
||||
|
||||
nestedDocument.archivedAt = new Date();
|
||||
await nestedDocument.save();
|
||||
|
||||
expect(collection.documentStructure).toBeNull();
|
||||
|
||||
await collection.addDocumentToStructure(document, undefined, {
|
||||
includeArchived: true,
|
||||
});
|
||||
|
||||
expect(collection.documentStructure).not.toBeNull();
|
||||
expect(collection.documentStructure).toHaveLength(1);
|
||||
expect(collection.documentStructure![0].id).toBe(document.id);
|
||||
expect(collection.documentStructure![0].children).toHaveLength(1);
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(
|
||||
nestedDocument.id
|
||||
);
|
||||
});
|
||||
describe("options: documentJson", () => {
|
||||
test("should append supplied json over document's own", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
NonNullFindOptions,
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
EmptyResultError,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Sequelize,
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
DataType,
|
||||
Length as SimpleLength,
|
||||
BeforeDestroy,
|
||||
IsDate,
|
||||
AllowNull,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type { CollectionSort, ProsemirrorData } from "@shared/types";
|
||||
@@ -54,6 +57,10 @@ import IsHexColor from "./validators/IsHexColor";
|
||||
import Length from "./validators/Length";
|
||||
import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
|
||||
type AdditionalFindOptions = {
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
@Scopes(() => ({
|
||||
withAllMemberships: {
|
||||
include: [
|
||||
@@ -99,6 +106,13 @@ import NotContainsUrl from "./validators/NotContainsUrl";
|
||||
},
|
||||
],
|
||||
}),
|
||||
withArchivedBy: () => ({
|
||||
include: [
|
||||
{
|
||||
association: "archivedBy",
|
||||
},
|
||||
],
|
||||
}),
|
||||
withMembership: (userId: string) => {
|
||||
if (!userId) {
|
||||
return {};
|
||||
@@ -249,6 +263,11 @@ class Collection extends ParanoidModel<
|
||||
})
|
||||
sort: CollectionSort;
|
||||
|
||||
/** Whether the collection is archived, and if so when. */
|
||||
@IsDate
|
||||
@Column
|
||||
archivedAt: Date | null;
|
||||
|
||||
// getters
|
||||
|
||||
/**
|
||||
@@ -268,6 +287,16 @@ class Collection extends ParanoidModel<
|
||||
return `/collection/${slugify(this.name)}-${this.urlId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this collection is considered active or not. A collection is active if
|
||||
* it has not been archived or deleted.
|
||||
*
|
||||
* @returns boolean
|
||||
*/
|
||||
get isActive(): boolean {
|
||||
return !this.archivedAt && !this.deletedAt;
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@BeforeValidate
|
||||
@@ -321,6 +350,14 @@ class Collection extends ParanoidModel<
|
||||
@Column(DataType.UUID)
|
||||
importId: string | null;
|
||||
|
||||
@BelongsTo(() => User, "archivedById")
|
||||
archivedBy?: User | null;
|
||||
|
||||
@AllowNull
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
archivedById?: string | null;
|
||||
|
||||
@HasMany(() => Document, "collectionId")
|
||||
documents: Document[];
|
||||
|
||||
@@ -390,37 +427,51 @@ class Collection extends ParanoidModel<
|
||||
*/
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options?: NonNullFindOptions<Collection>
|
||||
options?: NonNullFindOptions<Collection> & AdditionalFindOptions
|
||||
): Promise<Collection>;
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options?: FindOptions<Collection>
|
||||
options?: FindOptions<Collection> & AdditionalFindOptions
|
||||
): Promise<Collection | null>;
|
||||
static async findByPk(
|
||||
id: Identifier,
|
||||
options: FindOptions<Collection> = {}
|
||||
options: FindOptions<Collection> & AdditionalFindOptions = {}
|
||||
): Promise<Collection | null> {
|
||||
if (typeof id !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isUUID(id)) {
|
||||
return this.findOne({
|
||||
const collection = await this.findOne({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
...options,
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!collection && options.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
const match = id.match(UrlHelper.SLUG_URL_REGEX);
|
||||
if (match) {
|
||||
return this.findOne({
|
||||
const collection = await this.findOne({
|
||||
where: {
|
||||
urlId: match[1],
|
||||
},
|
||||
...options,
|
||||
rejectOnEmpty: false,
|
||||
});
|
||||
|
||||
if (!collection && options.rejectOnEmpty) {
|
||||
throw new EmptyResultError(`Collection doesn't exist with id: ${id}`);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -662,6 +713,7 @@ class Collection extends ParanoidModel<
|
||||
options: FindOptions & {
|
||||
save?: boolean;
|
||||
documentJson?: NavigationNode;
|
||||
includeArchived?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
if (!this.documentStructure) {
|
||||
|
||||
@@ -975,68 +975,76 @@ class Document extends ArchivableModel<
|
||||
|
||||
// Moves a document from being visible to the team within a collection
|
||||
// to the archived area, where it can be subsequently restored.
|
||||
archive = async (user: User) => {
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
archive = async (user: User, options?: FindOptions) => {
|
||||
const { transaction } = { ...options };
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collection) {
|
||||
await collection.removeDocumentInStructure(this, { transaction });
|
||||
if (this.collection) {
|
||||
this.collection.documentStructure = collection.documentStructure;
|
||||
}
|
||||
if (collection) {
|
||||
await collection.removeDocumentInStructure(this, { transaction });
|
||||
if (this.collection) {
|
||||
this.collection.documentStructure = collection.documentStructure;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.archiveWithChildren(user);
|
||||
await this.archiveWithChildren(user, { transaction });
|
||||
return this;
|
||||
};
|
||||
|
||||
// Restore an archived document back to being visible to the team
|
||||
unarchive = async (user: User) => {
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = this.collectionId
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// check to see if the documents parent hasn't been archived also
|
||||
// If it has then restore the document to the collection root.
|
||||
if (this.parentDocumentId) {
|
||||
const parent = await (this.constructor as typeof Document).findOne({
|
||||
where: {
|
||||
id: this.parentDocumentId,
|
||||
},
|
||||
});
|
||||
if (parent?.isDraft || !parent?.isActive) {
|
||||
this.parentDocumentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.template && this.publishedAt && collection) {
|
||||
await collection.addDocumentToStructure(this, undefined, {
|
||||
restoreTo = async (
|
||||
collectionId: string,
|
||||
options: FindOptions & { user: User }
|
||||
) => {
|
||||
const { transaction } = { ...options };
|
||||
const collection = collectionId
|
||||
? await Collection.findByPk(collectionId, {
|
||||
transaction,
|
||||
});
|
||||
if (this.collection) {
|
||||
this.collection.documentStructure = collection.documentStructure;
|
||||
}
|
||||
}
|
||||
});
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (this.deletedAt) {
|
||||
await this.restore();
|
||||
// check to see if the documents parent hasn't been archived also
|
||||
// If it has then restore the document to the collection root.
|
||||
if (this.parentDocumentId) {
|
||||
const parent = await (this.constructor as typeof Document).findOne({
|
||||
where: {
|
||||
id: this.parentDocumentId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (parent?.isDraft || !parent?.isActive) {
|
||||
this.parentDocumentId = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.archivedAt = null;
|
||||
this.lastModifiedById = user.id;
|
||||
this.updatedBy = user;
|
||||
await this.save();
|
||||
if (!this.template && this.publishedAt && collection?.isActive) {
|
||||
await collection.addDocumentToStructure(this, undefined, {
|
||||
includeArchived: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.deletedAt) {
|
||||
await this.restore({ transaction });
|
||||
this.collectionId = collectionId;
|
||||
await this.save({ transaction });
|
||||
}
|
||||
|
||||
if (this.archivedAt) {
|
||||
await this.restoreWithChildren(collectionId, options.user, {
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.collection && collection) {
|
||||
// updating the document structure in memory just in case it's later accessed somewhere
|
||||
this.collection.documentStructure = collection.documentStructure;
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -1088,7 +1096,7 @@ class Document extends ArchivableModel<
|
||||
* @returns Promise resolving to a NavigationNode
|
||||
*/
|
||||
toNavigationNode = async (
|
||||
options?: FindOptions<Document>
|
||||
options?: FindOptions<Document> & { includeArchived?: boolean }
|
||||
): Promise<NavigationNode> => {
|
||||
// Checking if the record is new is a performance optimization – new docs cannot have children
|
||||
const childDocuments = this.isNewRecord
|
||||
@@ -1097,16 +1105,24 @@ class Document extends ArchivableModel<
|
||||
.unscoped()
|
||||
.scope("withoutState")
|
||||
.findAll({
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
parentDocumentId: this.id,
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
},
|
||||
where: options?.includeArchived
|
||||
? {
|
||||
teamId: this.teamId,
|
||||
parentDocumentId: this.id,
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
}
|
||||
: {
|
||||
teamId: this.teamId,
|
||||
parentDocumentId: this.id,
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
transaction: options?.transaction,
|
||||
});
|
||||
|
||||
@@ -1124,6 +1140,38 @@ class Document extends ArchivableModel<
|
||||
};
|
||||
};
|
||||
|
||||
private restoreWithChildren = async (
|
||||
collectionId: string,
|
||||
user: User,
|
||||
options?: FindOptions<Document>
|
||||
) => {
|
||||
const restoreChildren = async (parentDocumentId: string) => {
|
||||
const childDocuments = await (
|
||||
this.constructor as typeof Document
|
||||
).findAll({
|
||||
where: {
|
||||
parentDocumentId,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
for (const child of childDocuments) {
|
||||
await restoreChildren(child.id);
|
||||
child.archivedAt = null;
|
||||
child.lastModifiedById = user.id;
|
||||
child.updatedBy = user;
|
||||
child.collectionId = collectionId;
|
||||
await child.save(options);
|
||||
}
|
||||
};
|
||||
|
||||
await restoreChildren(this.id);
|
||||
this.archivedAt = null;
|
||||
this.lastModifiedById = user.id;
|
||||
this.updatedBy = user;
|
||||
this.collectionId = collectionId;
|
||||
return this.save(options);
|
||||
};
|
||||
|
||||
private archiveWithChildren = async (
|
||||
user: User,
|
||||
options?: FindOptions<Document>
|
||||
@@ -1138,6 +1186,7 @@ class Document extends ArchivableModel<
|
||||
where: {
|
||||
parentDocumentId,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
for (const child of childDocuments) {
|
||||
await archiveChildren(child.id);
|
||||
|
||||
@@ -9,6 +9,23 @@ import {
|
||||
import { serialize } from "./index";
|
||||
|
||||
describe("admin", () => {
|
||||
it("should allow team admin to archive collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", admin.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(admin, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBeTruthy();
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.archive).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should allow updating collection but not reading documents", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({
|
||||
@@ -29,6 +46,7 @@ describe("admin", () => {
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.archive).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should allow updating documents in view only collection", async () => {
|
||||
@@ -40,47 +58,76 @@ describe("admin", () => {
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
const abilities = serialize(user, collection);
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, reloaded);
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBeTruthy();
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.archive).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("member", () => {
|
||||
describe("admin permission", () => {
|
||||
it("should allow updating collection", async () => {
|
||||
it("should allow member to update collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
});
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: CollectionPermission.Admin,
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const member = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
await collection.$add("user", member, {
|
||||
through: {
|
||||
permission: CollectionPermission.Admin,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, reloaded);
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
// expect(abilities.createDocument).toBeTruthy();
|
||||
// expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBeTruthy();
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toBeTruthy();
|
||||
expect(abilities.archive).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("read_write permission", () => {
|
||||
it("should disallow member to update collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const member = await buildUser({ teamId: team.id });
|
||||
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
await collection.$add("user", member, {
|
||||
through: {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toBe(false);
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBeTruthy();
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.archive).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow read write documents for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
@@ -95,6 +142,7 @@ describe("member", () => {
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
|
||||
it("should override read membership permission", async () => {
|
||||
@@ -121,10 +169,38 @@ describe("member", () => {
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("read permission", () => {
|
||||
it("should disallow member to archive collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const member = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
await collection.$add("user", member, {
|
||||
through: {
|
||||
permission: CollectionPermission.Read,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).not.toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.updateDocument).toBe(false);
|
||||
expect(abilities.createDocument).toBe(false);
|
||||
expect(abilities.archive).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow read permissions for team member", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
@@ -138,32 +214,33 @@ describe("member", () => {
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
|
||||
it("should allow override with read_write membership permission", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({
|
||||
teamId: team.id,
|
||||
});
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const member = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
permission: CollectionPermission.Read,
|
||||
});
|
||||
await UserMembership.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
await collection.$add("user", member, {
|
||||
through: {
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
createdById: admin.id,
|
||||
},
|
||||
});
|
||||
// reload to get membership
|
||||
const reloaded = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
method: ["withMembership", member.id],
|
||||
}).findByPk(collection.id);
|
||||
const abilities = serialize(user, reloaded);
|
||||
const abilities = serialize(member, reloaded);
|
||||
expect(abilities.read).toBeTruthy();
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +260,7 @@ describe("member", () => {
|
||||
expect(abilities.createDocument).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
|
||||
it("should allow override with team member membership permission", async () => {
|
||||
@@ -210,6 +288,7 @@ describe("member", () => {
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -232,6 +311,7 @@ describe("viewer", () => {
|
||||
expect(abilities.createDocument).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
|
||||
it("should override read membership permission", async () => {
|
||||
@@ -259,6 +339,7 @@ describe("viewer", () => {
|
||||
expect(abilities.readDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -289,6 +370,7 @@ describe("viewer", () => {
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,6 +389,7 @@ describe("viewer", () => {
|
||||
expect(abilities.read).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
|
||||
it("should allow override with team member membership permission", async () => {
|
||||
@@ -335,6 +418,7 @@ describe("viewer", () => {
|
||||
expect(abilities.createDocument).toBeTruthy();
|
||||
expect(abilities.share).toBeTruthy();
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -357,6 +441,7 @@ describe("guest", () => {
|
||||
expect(abilities.createDocument).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,5 +471,6 @@ describe("guest", () => {
|
||||
expect(abilities.createDocument).toEqual(false);
|
||||
expect(abilities.share).toEqual(false);
|
||||
expect(abilities.update).toEqual(false);
|
||||
expect(abilities.archive).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ allow(User, "move", Collection, (actor, collection) =>
|
||||
//
|
||||
isTeamAdmin(actor, collection),
|
||||
isTeamMutable(actor),
|
||||
!collection?.deletedAt
|
||||
!!collection?.isActive
|
||||
)
|
||||
);
|
||||
|
||||
@@ -105,14 +105,38 @@ allow(User, "share", Collection, (user, collection) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, "updateDocument", Collection, (user, collection) => {
|
||||
if (!collection || !isTeamModel(user, collection) || !isTeamMutable(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!collection.isPrivate && user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
collection.permission !== CollectionPermission.ReadWrite ||
|
||||
user.isViewer ||
|
||||
user.isGuest
|
||||
) {
|
||||
return includesMembership(collection, [
|
||||
CollectionPermission.ReadWrite,
|
||||
CollectionPermission.Admin,
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(
|
||||
User,
|
||||
["updateDocument", "createDocument", "deleteDocument"],
|
||||
["createDocument", "deleteDocument"],
|
||||
Collection,
|
||||
(user, collection) => {
|
||||
if (
|
||||
!collection ||
|
||||
user.teamId !== collection.teamId ||
|
||||
!collection.isActive ||
|
||||
!isTeamModel(user, collection) ||
|
||||
!isTeamMutable(user)
|
||||
) {
|
||||
return false;
|
||||
@@ -137,16 +161,38 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, ["update", "delete"], Collection, (user, collection) => {
|
||||
if (!collection || user.isGuest || user.teamId !== collection.teamId) {
|
||||
return false;
|
||||
}
|
||||
if (user.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
allow(User, ["update", "archive"], Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!!collection?.isActive,
|
||||
or(
|
||||
isTeamAdmin(user, collection),
|
||||
includesMembership(collection, [CollectionPermission.Admin])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return includesMembership(collection, [CollectionPermission.Admin]);
|
||||
});
|
||||
allow(User, "delete", Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!collection?.deletedAt,
|
||||
or(
|
||||
isTeamAdmin(user, collection),
|
||||
includesMembership(collection, [CollectionPermission.Admin])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "restore", Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!collection?.isActive,
|
||||
or(
|
||||
isTeamAdmin(user, collection),
|
||||
includesMembership(collection, [CollectionPermission.Admin])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
function includesMembership(
|
||||
collection: Collection | null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Collection from "@server/models/Collection";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { APIContext } from "@server/types";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default async function presentCollection(
|
||||
ctx: APIContext | undefined,
|
||||
@@ -24,5 +25,7 @@ export default async function presentCollection(
|
||||
createdAt: collection.createdAt,
|
||||
updatedAt: collection.updatedAt,
|
||||
deletedAt: collection.deletedAt,
|
||||
archivedAt: collection.archivedAt,
|
||||
archivedBy: collection.archivedBy && presentUser(collection.archivedBy),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import concat from "lodash/concat";
|
||||
import uniq from "lodash/uniq";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Server } from "socket.io";
|
||||
import {
|
||||
Comment,
|
||||
@@ -41,8 +43,7 @@ export default class WebsocketsProcessor {
|
||||
case "documents.create":
|
||||
case "documents.publish":
|
||||
case "documents.unpublish":
|
||||
case "documents.restore":
|
||||
case "documents.unarchive": {
|
||||
case "documents.restore": {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
paranoid: false,
|
||||
});
|
||||
@@ -54,6 +55,7 @@ export default class WebsocketsProcessor {
|
||||
}
|
||||
|
||||
const channels = await this.getDocumentEventChannels(event, document);
|
||||
|
||||
return socketio.to(channels).emit("entities", {
|
||||
event: event.name,
|
||||
fetchIfMissing: true,
|
||||
@@ -71,6 +73,50 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.unarchive": {
|
||||
const [document, srcCollection] = await Promise.all([
|
||||
Document.findByPk(event.documentId, { paranoid: false }),
|
||||
Collection.findByPk(event.data.sourceCollectionId, {
|
||||
paranoid: false,
|
||||
}),
|
||||
]);
|
||||
if (!document || !srcCollection) {
|
||||
return;
|
||||
}
|
||||
const documentChannels = await this.getDocumentEventChannels(
|
||||
event,
|
||||
document
|
||||
);
|
||||
const collectionChannels = this.getCollectionEventChannels(
|
||||
event,
|
||||
srcCollection
|
||||
);
|
||||
|
||||
const channels = uniq(concat(documentChannels, collectionChannels));
|
||||
|
||||
return socketio.to(channels).emit("entities", {
|
||||
event: event.name,
|
||||
fetchIfMissing: true,
|
||||
documentIds: [
|
||||
{
|
||||
id: document.id,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
],
|
||||
collectionIds: uniqBy(
|
||||
[
|
||||
{
|
||||
id: document.collectionId,
|
||||
},
|
||||
{
|
||||
id: srcCollection.id,
|
||||
},
|
||||
],
|
||||
"id"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
case "documents.permanent_delete": {
|
||||
return socketio
|
||||
.to(`collection-${event.collectionId}`)
|
||||
@@ -235,6 +281,21 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "collections.archive":
|
||||
case "collections.restore": {
|
||||
const collection = await Collection.findByPk(event.collectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
return socketio
|
||||
.to(this.getCollectionEventChannels(event, collection))
|
||||
.emit(event.name, {
|
||||
id: event.collectionId,
|
||||
archivedAt: event.data.archivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
case "collections.move": {
|
||||
return socketio
|
||||
.to(`collection-${event.collectionId}`)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { CollectionPermission, CollectionStatusFilter } from "@shared/types";
|
||||
import { Document, UserMembership, GroupMembership } from "@server/models";
|
||||
import {
|
||||
buildUser,
|
||||
@@ -40,6 +40,44 @@ describe("#collections.list", () => {
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should include archived collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
statusFilter: [CollectionStatusFilter.Archived],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].archivedAt).toBeTruthy();
|
||||
expect(body.data[0].archivedBy).toBeTruthy();
|
||||
expect(body.data[0].archivedBy.id).toBe(collection.archivedById);
|
||||
});
|
||||
|
||||
it("should exclude archived collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should not return private collections actor is not a member of", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -122,6 +160,62 @@ describe("#collections.list", () => {
|
||||
expect(body.policies.length).toEqual(2);
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not include archived collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
const res = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not include archived collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const beforeArchiveRes = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const beforeArchiveBody = await beforeArchiveRes.json();
|
||||
expect(beforeArchiveRes.status).toEqual(200);
|
||||
expect(beforeArchiveBody.data).toHaveLength(1);
|
||||
expect(beforeArchiveBody.data[0].id).toEqual(collection.id);
|
||||
|
||||
const archiveRes = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(archiveRes.status).toEqual(200);
|
||||
|
||||
const afterArchiveRes = await server.post("/api/collections.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
const afterArchiveBody = await afterArchiveRes.json();
|
||||
expect(afterArchiveRes.status).toEqual(200);
|
||||
expect(afterArchiveBody.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#collections.import", () => {
|
||||
@@ -1056,6 +1150,26 @@ describe("#collections.memberships", () => {
|
||||
});
|
||||
|
||||
describe("#collections.info", () => {
|
||||
it("should return archivedBy for archived collections", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
archivedAt: new Date(),
|
||||
archivedById: user.id,
|
||||
});
|
||||
const res = await server.post("/api/collections.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.archivedBy.id).toEqual(collection.archivedById);
|
||||
});
|
||||
|
||||
it("should return collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -1705,3 +1819,76 @@ describe("#collections.delete", () => {
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#collections.archive", () => {
|
||||
it("should archive collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const document = await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
await collection.reload();
|
||||
expect(collection.documentStructure).not.toBe(null);
|
||||
expect(document.archivedAt).toBe(null);
|
||||
const res = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
const [, , body] = await Promise.all([
|
||||
collection.reload(),
|
||||
document.reload(),
|
||||
res.json(),
|
||||
]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.archivedAt).not.toBe(null);
|
||||
expect(body.data.archivedBy).toBeTruthy();
|
||||
expect(body.data.archivedBy.id).toBe(collection.archivedById);
|
||||
expect(document.archivedAt).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#collections.restore", () => {
|
||||
it("should restore collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
});
|
||||
await buildDocument({
|
||||
collectionId: collection.id,
|
||||
teamId: team.id,
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
// reload to ensure documentStructure is set
|
||||
await collection.reload();
|
||||
expect(collection.documentStructure).not.toBe(null);
|
||||
const archiveRes = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
const [, archiveBody] = await Promise.all([
|
||||
collection.reload(),
|
||||
archiveRes.json(),
|
||||
]);
|
||||
expect(archiveRes.status).toEqual(200);
|
||||
expect(archiveBody.data.archivedAt).not.toBe(null);
|
||||
const res = await server.post("/api/collections.restore", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
const [, body] = await Promise.all([collection.reload(), res.json()]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.archivedAt).toBe(null);
|
||||
expect(collection.documentStructure).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import Router from "koa-router";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import {
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationState,
|
||||
FileOperationType,
|
||||
} from "@shared/types";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
Group,
|
||||
Attachment,
|
||||
FileOperation,
|
||||
Document,
|
||||
} from "@server/models";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { authorize } from "@server/policies";
|
||||
@@ -125,9 +127,12 @@ router.post(
|
||||
async (ctx: APIContext<T.CollectionsInfoReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
const collection = await Collection.scope([
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
"withArchivedBy",
|
||||
]).findByPk(id);
|
||||
|
||||
authorize(user, "read", collection);
|
||||
|
||||
@@ -801,23 +806,60 @@ router.post(
|
||||
auth(),
|
||||
validate(T.CollectionsListSchema),
|
||||
pagination(),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsListReq>) => {
|
||||
const { includeListOnly } = ctx.input.body;
|
||||
const { includeListOnly, statusFilter } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const collectionIds = await user.collectionIds();
|
||||
const where: WhereOptions<Collection> =
|
||||
includeListOnly && user.isAdmin
|
||||
? {
|
||||
teamId: user.teamId,
|
||||
}
|
||||
: {
|
||||
teamId: user.teamId,
|
||||
id: collectionIds,
|
||||
};
|
||||
const { transaction } = ctx.state;
|
||||
const collectionIds = await user.collectionIds({ transaction });
|
||||
|
||||
const where: WhereOptions<Collection> = {
|
||||
teamId: user.teamId,
|
||||
[Op.and]: [
|
||||
{
|
||||
deletedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (!statusFilter) {
|
||||
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
|
||||
}
|
||||
|
||||
if (!includeListOnly || !user.isAdmin) {
|
||||
where[Op.and].push({ id: collectionIds });
|
||||
}
|
||||
|
||||
const statusQuery = [];
|
||||
if (statusFilter?.includes(CollectionStatusFilter.Archived)) {
|
||||
statusQuery.push({
|
||||
archivedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (statusQuery.length) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: statusQuery,
|
||||
});
|
||||
}
|
||||
|
||||
const [collections, total] = await Promise.all([
|
||||
Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findAll({
|
||||
Collection.scope(
|
||||
statusFilter?.includes(CollectionStatusFilter.Archived)
|
||||
? [
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
"withArchivedBy",
|
||||
]
|
||||
: {
|
||||
method: ["withMembership", user.id],
|
||||
}
|
||||
).findAll({
|
||||
where,
|
||||
order: [
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
@@ -825,8 +867,9 @@ router.post(
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
transaction,
|
||||
}),
|
||||
Collection.count({ where }),
|
||||
Collection.count({ where, transaction }),
|
||||
]);
|
||||
|
||||
const nullIndex = collections.findIndex(
|
||||
@@ -834,7 +877,9 @@ router.post(
|
||||
);
|
||||
|
||||
if (nullIndex !== -1) {
|
||||
const indexedCollections = await collectionIndexing(user.teamId);
|
||||
const indexedCollections = await collectionIndexing(user.teamId, {
|
||||
transaction,
|
||||
});
|
||||
collections.forEach((collection) => {
|
||||
collection.index = indexedCollections[collection.id];
|
||||
});
|
||||
@@ -881,6 +926,130 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"collections.archive",
|
||||
auth(),
|
||||
validate(T.CollectionsArchiveSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsArchiveReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const collection = await Collection.scope([
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findByPk(id, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
authorize(user, "archive", collection);
|
||||
|
||||
collection.archivedAt = new Date();
|
||||
collection.archivedById = user.id;
|
||||
await collection.save({ transaction });
|
||||
collection.archivedBy = user;
|
||||
|
||||
// Archive all documents within the collection
|
||||
await Document.update(
|
||||
{
|
||||
lastModifiedById: user.id,
|
||||
archivedAt: collection.archivedAt,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
teamId: collection.teamId,
|
||||
collectionId: collection.id,
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "collections.archive",
|
||||
collectionId: collection.id,
|
||||
data: {
|
||||
name: collection.name,
|
||||
archivedAt: collection.archivedAt,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentCollection(ctx, collection),
|
||||
policies: presentPolicies(user, [collection]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"collections.restore",
|
||||
auth(),
|
||||
validate(T.CollectionsRestoreSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsRestoreReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
authorize(user, "restore", collection);
|
||||
|
||||
const collectionArchivedAt = collection.archivedAt;
|
||||
|
||||
await Document.update(
|
||||
{
|
||||
lastModifiedById: user.id,
|
||||
archivedAt: null,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
archivedAt: collection.archivedAt,
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
collection.archivedAt = null;
|
||||
collection.archivedById = null;
|
||||
await collection.save({ transaction });
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "collections.restore",
|
||||
collectionId: collection.id,
|
||||
data: {
|
||||
name: collection.name,
|
||||
archivedAt: collectionArchivedAt,
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentCollection(ctx, collection!),
|
||||
policies: presentPolicies(user, [collection]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"collections.move",
|
||||
auth(),
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import isUndefined from "lodash/isUndefined";
|
||||
import { z } from "zod";
|
||||
import { CollectionPermission, FileOperationFormat } from "@shared/types";
|
||||
import {
|
||||
CollectionPermission,
|
||||
CollectionStatusFilter,
|
||||
FileOperationFormat,
|
||||
} from "@shared/types";
|
||||
import { Collection } from "@server/models";
|
||||
import { zodIconType } from "@server/utils/zod";
|
||||
import { ValidateColor, ValidateIndex } from "@server/validation";
|
||||
@@ -174,6 +178,8 @@ export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
|
||||
export const CollectionsListSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
includeListOnly: z.boolean().default(false),
|
||||
/** Collection statuses to include in results */
|
||||
statusFilter: z.nativeEnum(CollectionStatusFilter).array().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -185,6 +191,22 @@ export const CollectionsDeleteSchema = BaseSchema.extend({
|
||||
|
||||
export type CollectionsDeleteReq = z.infer<typeof CollectionsDeleteSchema>;
|
||||
|
||||
export const CollectionsArchiveSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type CollectionsArchiveReq = z.infer<typeof CollectionsArchiveSchema>;
|
||||
|
||||
export const CollectionsRestoreSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type CollectionsRestoreReq = z.infer<typeof CollectionsRestoreSchema>;
|
||||
|
||||
export const CollectionsArchivedSchema = BaseSchema;
|
||||
|
||||
export type CollectionsArchivedReq = z.infer<typeof CollectionsArchivedSchema>;
|
||||
|
||||
export const CollectionsMoveSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema.extend({
|
||||
index: z
|
||||
|
||||
@@ -806,6 +806,85 @@ describe("#documents.list", () => {
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return only archived documents in a collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const docs = await Promise.all([
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
}),
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
}),
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
}),
|
||||
]);
|
||||
await docs[0].archive(user);
|
||||
const res = await server.post("/api/documents.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
statusFilter: [StatusFilter.Archived],
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].id).toEqual(docs[0].id);
|
||||
});
|
||||
|
||||
it("should return archived documents across all collections user has access to", async () => {
|
||||
const user = await buildUser();
|
||||
const collections = await Promise.all([
|
||||
buildCollection({
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
buildCollection({
|
||||
teamId: user.teamId,
|
||||
}),
|
||||
]);
|
||||
const docs = await Promise.all([
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collections[0].id,
|
||||
}),
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collections[1].id,
|
||||
}),
|
||||
buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collections[1].id,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([docs[0].archive(user), docs[1].archive(user)]);
|
||||
const res = await server.post("/api/documents.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
statusFilter: [StatusFilter.Archived],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data).toHaveLength(2);
|
||||
const docIds = body.data.map((doc: any) => doc.id);
|
||||
expect(docIds).toContain(docs[0].id);
|
||||
expect(docIds).toContain(docs[1].id);
|
||||
expect(docIds).not.toContain(docs[2].id);
|
||||
});
|
||||
|
||||
it("should not return documents in private collections not a member of", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -2678,6 +2757,131 @@ describe("#documents.move", () => {
|
||||
});
|
||||
|
||||
describe("#documents.restore", () => {
|
||||
it("should correctly restore document from an archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherCollection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const archiveRes = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(archiveRes.status).toEqual(200);
|
||||
|
||||
// check if document is part of the correct collection's structure
|
||||
await collection.reload();
|
||||
expect(collection.archivedAt).not.toBe(null);
|
||||
expect(collection.documentStructure).not.toBe(null);
|
||||
expect(collection.documentStructure).toHaveLength(1);
|
||||
expect(collection?.documentStructure?.[0].id).toBe(document.id);
|
||||
expect(anotherCollection.documentStructure).toBeNull();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: anotherCollection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const [, , body] = await Promise.all([
|
||||
collection.reload(),
|
||||
anotherCollection.reload(),
|
||||
res.json(),
|
||||
]);
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.deletedAt).toEqual(null);
|
||||
expect(body.data.collectionId).toEqual(anotherCollection.id);
|
||||
|
||||
// re-check collection structure after restore
|
||||
expect(collection.documentStructure).toHaveLength(0);
|
||||
expect(anotherCollection.documentStructure).not.toBe(null);
|
||||
expect(anotherCollection.documentStructure).toHaveLength(1);
|
||||
expect(anotherCollection?.documentStructure?.[0].id).toBe(document.id);
|
||||
});
|
||||
|
||||
it("should fail if attempting to restore document to an archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const archiveRes = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(archiveRes.status).toEqual(200);
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail if attempting to restore to a collection for which the user does not have access", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const archiveRes = await server.post("/api/collections.archive", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(archiveRes.status).toEqual(200);
|
||||
|
||||
const anotherCollection = await buildCollection();
|
||||
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: anotherCollection.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require id", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -2788,13 +2992,58 @@ describe("#documents.restore", () => {
|
||||
});
|
||||
await document.destroy();
|
||||
await collection.destroy({ hooks: false });
|
||||
// passing deleted collection's id
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
// not passing collection's id
|
||||
const anotherRes = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
const anotherBody = await anotherRes.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
expect(anotherRes.status).toEqual(400);
|
||||
expect(anotherBody.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow restore of documents in archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await document.destroy();
|
||||
collection.archivedAt = new Date();
|
||||
await collection.save();
|
||||
const res = await server.post("/api/documents.restore", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: document.id,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow restore of trashed documents to collection user cannot access", async () => {
|
||||
|
||||
@@ -5,11 +5,13 @@ import invariant from "invariant";
|
||||
import JSZip from "jszip";
|
||||
import Router from "koa-router";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import has from "lodash/has";
|
||||
import remove from "lodash/remove";
|
||||
import uniq from "lodash/uniq";
|
||||
import mime from "mime-types";
|
||||
import { Op, ScopeOptions, Sequelize, WhereOptions } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { TeamPreference, UserRole } from "@shared/types";
|
||||
import { StatusFilter, TeamPreference, UserRole } from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import documentCreator from "@server/commands/documentCreator";
|
||||
@@ -20,7 +22,6 @@ import documentPermanentDeleter from "@server/commands/documentPermanentDeleter"
|
||||
import documentUpdater from "@server/commands/documentUpdater";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
NotFoundError,
|
||||
InvalidRequestError,
|
||||
AuthenticationError,
|
||||
ValidationError,
|
||||
@@ -83,43 +84,52 @@ router.post(
|
||||
pagination(),
|
||||
validate(T.DocumentsListSchema),
|
||||
async (ctx: APIContext<T.DocumentsListReq>) => {
|
||||
let { sort } = ctx.input.body;
|
||||
const {
|
||||
sort,
|
||||
direction,
|
||||
template,
|
||||
collectionId,
|
||||
backlinkDocumentId,
|
||||
parentDocumentId,
|
||||
userId: createdById,
|
||||
statusFilter,
|
||||
} = ctx.input.body;
|
||||
|
||||
// always filter by the current team
|
||||
const { user } = ctx.state.auth;
|
||||
let where: WhereOptions<Document> = {
|
||||
const where: WhereOptions<Document> = {
|
||||
teamId: user.teamId,
|
||||
archivedAt: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
[Op.and]: [
|
||||
{
|
||||
deletedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exclude archived docs by default
|
||||
if (!statusFilter) {
|
||||
where[Op.and].push({ archivedAt: { [Op.eq]: null } });
|
||||
}
|
||||
|
||||
if (template) {
|
||||
where = {
|
||||
...where,
|
||||
where[Op.and].push({
|
||||
template: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// if a specific user is passed then add to filters. If the user doesn't
|
||||
// exist in the team then nothing will be returned, so no need to check auth
|
||||
if (createdById) {
|
||||
where = { ...where, createdById };
|
||||
where[Op.and].push({ createdById });
|
||||
}
|
||||
|
||||
let documentIds: string[] = [];
|
||||
|
||||
// if a specific collection is passed then we need to check auth to view it
|
||||
if (collectionId) {
|
||||
where = { ...where, collectionId };
|
||||
where[Op.and].push({ collectionId: [collectionId] });
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
@@ -131,19 +141,18 @@ router.post(
|
||||
documentIds = (collection?.documentStructure || [])
|
||||
.map((node) => node.id)
|
||||
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
|
||||
where = { ...where, id: documentIds };
|
||||
where[Op.and].push({ id: documentIds });
|
||||
} // otherwise, filter by all collections the user has access to
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds();
|
||||
where = {
|
||||
...where,
|
||||
where[Op.and].push({
|
||||
collectionId:
|
||||
template && can(user, "readTemplate", user.team)
|
||||
? {
|
||||
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
|
||||
}
|
||||
: collectionIds,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -177,21 +186,20 @@ router.post(
|
||||
]);
|
||||
|
||||
if (groupMembership || membership) {
|
||||
delete where.collectionId;
|
||||
remove(where[Op.and], (cond) => has(cond, "collectionId"));
|
||||
}
|
||||
|
||||
where = { ...where, parentDocumentId };
|
||||
where[Op.and].push({ parentDocumentId });
|
||||
}
|
||||
|
||||
// Explicitly passing 'null' as the parentDocumentId allows listing documents
|
||||
// that have no parent document (aka they are at the root of the collection)
|
||||
if (parentDocumentId === null) {
|
||||
where = {
|
||||
...where,
|
||||
where[Op.and].push({
|
||||
parentDocumentId: {
|
||||
[Op.is]: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (backlinkDocumentId) {
|
||||
@@ -201,29 +209,81 @@ router.post(
|
||||
documentId: backlinkDocumentId,
|
||||
},
|
||||
});
|
||||
where = {
|
||||
...where,
|
||||
where[Op.and].push({
|
||||
id: backlinks.map((backlink) => backlink.reverseDocumentId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (sort === "index") {
|
||||
sort = "updatedAt";
|
||||
const statusQuery = [];
|
||||
if (statusFilter?.includes(StatusFilter.Published)) {
|
||||
statusQuery.push({
|
||||
[Op.and]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilter?.includes(StatusFilter.Draft)) {
|
||||
statusQuery.push({
|
||||
[Op.and]: [
|
||||
{
|
||||
publishedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
archivedAt: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
[Op.or]: [
|
||||
// Only ever include draft results for the user's own documents
|
||||
{ createdById: user.id },
|
||||
{ "$memberships.id$": { [Op.ne]: null } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilter?.includes(StatusFilter.Archived)) {
|
||||
statusQuery.push({
|
||||
archivedAt: {
|
||||
[Op.ne]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (statusQuery.length) {
|
||||
where[Op.and].push({
|
||||
[Op.or]: statusQuery,
|
||||
});
|
||||
}
|
||||
|
||||
const [documents, total] = await Promise.all([
|
||||
Document.defaultScopeWithUser(user.id).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
order: [
|
||||
[
|
||||
// this needs to be done otherwise findAll will throw citing
|
||||
// that the column "document"."index" doesn't exist – value of sort
|
||||
// is required to be a column name
|
||||
sort === "index" ? "updatedAt" : sort,
|
||||
direction,
|
||||
],
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
Document.count({ where }),
|
||||
]);
|
||||
|
||||
// index sort is special because it uses the order of the documents in the
|
||||
// collection.documentStructure rather than a database column
|
||||
if (documentIds.length) {
|
||||
if (sort === "index") {
|
||||
// sort again so as to retain the order of documents as in collection.documentStructure
|
||||
documents.sort(
|
||||
(a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id)
|
||||
);
|
||||
@@ -233,6 +293,7 @@ router.post(
|
||||
documents.map((document) => presentDocument(ctx, document))
|
||||
);
|
||||
const policies = presentPolicies(user, documents);
|
||||
|
||||
ctx.body = {
|
||||
pagination: { ...ctx.state.pagination, total },
|
||||
data,
|
||||
@@ -738,81 +799,105 @@ router.post(
|
||||
"documents.restore",
|
||||
auth({ role: UserRole.Member }),
|
||||
validate(T.DocumentsRestoreSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.DocumentsRestoreReq>) => {
|
||||
const { id, collectionId, revisionId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw NotFoundError();
|
||||
}
|
||||
const sourceCollectionId = document.collectionId;
|
||||
const destCollectionId = collectionId ?? sourceCollectionId;
|
||||
|
||||
// Passing collectionId allows restoring to a different collection than the
|
||||
// document was originally within
|
||||
if (collectionId) {
|
||||
document.collectionId = collectionId;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(document.collectionId)
|
||||
}).findByPk(sourceCollectionId)
|
||||
: undefined;
|
||||
|
||||
// if the collectionId was provided in the request and isn't valid then it will
|
||||
// be caught as a 403 on the authorize call below. Otherwise we're checking here
|
||||
// that the original collection still exists and advising to pass collectionId
|
||||
// if not.
|
||||
if (document.collection && !collectionId && !collection) {
|
||||
const destCollection = destCollectionId
|
||||
? await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(destCollectionId)
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore to original collection, it may have been deleted"
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId !== destCollectionId) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.unarchive(user);
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transaction }
|
||||
);
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, "unarchive", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously archived document
|
||||
await document.unarchive(user);
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.unarchive",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
await document.restoreTo(destCollectionId!, { transaction, user }); // destCollectionId is guaranteed to be defined here
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "documents.unarchive",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
sourceCollectionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transaction }
|
||||
);
|
||||
} else if (revisionId) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, "update", document);
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
const revision = await Revision.findByPk(revisionId, { transaction });
|
||||
authorize(document, "restore", revision);
|
||||
|
||||
document.restoreFromRevision(revision);
|
||||
await document.save();
|
||||
await document.save({ transaction });
|
||||
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "documents.restore",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transaction }
|
||||
);
|
||||
} else {
|
||||
assertPresent(revisionId, "revisionId is required");
|
||||
}
|
||||
@@ -1286,24 +1371,32 @@ router.post(
|
||||
"documents.archive",
|
||||
auth(),
|
||||
validate(T.DocumentsArchiveSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.DocumentsArchiveReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "archive", document);
|
||||
|
||||
await document.archive(user);
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "documents.archive",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
await document.archive(user, { transaction });
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "documents.archive",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
data: {
|
||||
title: document.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
|
||||
@@ -68,6 +68,9 @@ export const DocumentsListSchema = BaseSchema.extend({
|
||||
|
||||
/** Boolean which denotes whether the document is a template */
|
||||
template: z.boolean().optional(),
|
||||
|
||||
/** Document statuses to include in results */
|
||||
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
|
||||
}),
|
||||
// Maintains backwards compatibility
|
||||
}).transform((req) => {
|
||||
|
||||
@@ -284,6 +284,10 @@ export async function buildCollection(
|
||||
overrides.userId = user.id;
|
||||
}
|
||||
|
||||
if (overrides.archivedAt && !overrides.archivedById) {
|
||||
overrides.archivedById = overrides.userId;
|
||||
}
|
||||
|
||||
return Collection.create({
|
||||
name: faker.lorem.words(2),
|
||||
description: faker.lorem.words(4),
|
||||
|
||||
@@ -175,7 +175,6 @@ export type DocumentEvent = BaseEvent<Document> &
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.restore";
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
@@ -184,6 +183,16 @@ export type DocumentEvent = BaseEvent<Document> &
|
||||
source?: "import";
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: "documents.unarchive";
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
data: {
|
||||
title: string;
|
||||
/** Id of collection from which the document is unarchived */
|
||||
sourceCollectionId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: "documents.move";
|
||||
documentId: string;
|
||||
@@ -294,10 +303,15 @@ export type CollectionEvent = BaseEvent<Collection> &
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: "collections.update" | "collections.delete";
|
||||
name:
|
||||
| "collections.update"
|
||||
| "collections.delete"
|
||||
| "collections.archive"
|
||||
| "collections.restore";
|
||||
collectionId: string;
|
||||
data: {
|
||||
name: string;
|
||||
archivedAt: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { FindOptions } from "sequelize";
|
||||
import naturalSort from "@shared/utils/naturalSort";
|
||||
import { Collection, Document, Star } from "@server/models";
|
||||
|
||||
export async function collectionIndexing(
|
||||
teamId: string
|
||||
teamId: string,
|
||||
{ transaction }: FindOptions<Collection>
|
||||
): Promise<{ [id: string]: string }> {
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
@@ -12,6 +14,7 @@ export async function collectionIndexing(
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "name"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
const sortable = naturalSort(collections, (collection) => collection.name);
|
||||
@@ -23,7 +26,7 @@ export async function collectionIndexing(
|
||||
for (const collection of sortable) {
|
||||
if (collection.index === null) {
|
||||
collection.index = fractionalIndex(previousIndex, null);
|
||||
promises.push(collection.save());
|
||||
promises.push(collection.save({ transaction }));
|
||||
}
|
||||
|
||||
previousIndex = collection.index;
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
"Search in collection": "Search in collection",
|
||||
"Star": "Star",
|
||||
"Unstar": "Unstar",
|
||||
"Archive": "Archive",
|
||||
"Archive collection": "Archive collection",
|
||||
"Collection archived": "Collection archived",
|
||||
"Archiving": "Archiving",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
|
||||
"Restore": "Restore",
|
||||
"Collection restored": "Collection restored",
|
||||
"Delete": "Delete",
|
||||
"Delete collection": "Delete collection",
|
||||
"New template": "New template",
|
||||
@@ -72,10 +79,8 @@
|
||||
"Move": "Move",
|
||||
"Move to collection": "Move to collection",
|
||||
"Move {{ documentType }}": "Move {{ documentType }}",
|
||||
"Archive": "Archive",
|
||||
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
|
||||
"Document archived": "Document archived",
|
||||
"Archiving": "Archiving",
|
||||
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
|
||||
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
||||
"Permanently delete": "Permanently delete",
|
||||
@@ -342,6 +347,7 @@
|
||||
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
|
||||
"Logo": "Logo",
|
||||
"Archived collections": "Archived collections",
|
||||
"Change permissions?": "Change permissions?",
|
||||
"New doc": "New doc",
|
||||
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
|
||||
@@ -497,7 +503,6 @@
|
||||
"Show document menu": "Show document menu",
|
||||
"{{ documentName }} restored": "{{ documentName }} restored",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Choose a collection": "Choose a collection",
|
||||
"Enable embeds": "Enable embeds",
|
||||
"Export options": "Export options",
|
||||
@@ -558,6 +563,7 @@
|
||||
"{{ usersCount }} users with access_plural": "{{ usersCount }} users with access",
|
||||
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
|
||||
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Share": "Share",
|
||||
"Recently updated": "Recently updated",
|
||||
"Recently published": "Recently published",
|
||||
@@ -630,7 +636,6 @@
|
||||
"This document will be permanently deleted in <2></2> unless restored.": "This document will be permanently deleted in <2></2> unless restored.",
|
||||
"Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents": "Highlight some text and use the <1></1> control to add placeholders that can be filled out when creating new documents",
|
||||
"You’re editing a template": "You’re editing a template",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Deleted by {{userName}}": "Deleted by {{userName}}",
|
||||
"Observing {{ userName }}": "Observing {{ userName }}",
|
||||
"Backlinks": "Backlinks",
|
||||
|
||||
@@ -13,6 +13,10 @@ export enum StatusFilter {
|
||||
Draft = "draft",
|
||||
}
|
||||
|
||||
export enum CollectionStatusFilter {
|
||||
Archived = "archived",
|
||||
}
|
||||
|
||||
export enum CommentStatusFilter {
|
||||
Resolved = "resolved",
|
||||
Unresolved = "unresolved",
|
||||
|
||||
Reference in New Issue
Block a user