mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b012794f | |||
| ec1bad11d0 |
@@ -63,8 +63,9 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
React.useEffect(() => {
|
||||
if (documentId) {
|
||||
void documents.fetch(documentId);
|
||||
void membership.fetchDocuments();
|
||||
}
|
||||
}, [documentId, documents]);
|
||||
}, [documentId, documents, membership]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActiveDocument && membership.documentId) {
|
||||
@@ -115,9 +116,7 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
const node = document.asNavigationNode;
|
||||
const childDocuments = node.children;
|
||||
const hasChildDocuments = childDocuments.length > 0;
|
||||
const hasChildDocuments = (membership.documents?.length || 0) > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -172,13 +171,13 @@ function SharedWithMeLink({ membership, depth = 0 }: Props) {
|
||||
</Draggable>
|
||||
</Relative>
|
||||
<Folder expanded={displayChildDocuments}>
|
||||
{childDocuments.map((node, index) => (
|
||||
{membership.documents?.map((childNode, index) => (
|
||||
<DocumentLink
|
||||
key={node.id}
|
||||
node={node}
|
||||
key={childNode.id}
|
||||
node={childNode}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
isDraft={node.isDraft}
|
||||
isDraft={childNode.isDraft}
|
||||
depth={2}
|
||||
index={index}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,7 @@ import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialExcept,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketDocumentMovedEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
} from "~/types";
|
||||
@@ -147,9 +148,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
document = await documents.fetch(documentId, { force: true });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
@@ -176,6 +175,16 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const parentDocuments = document?.parentDocuments ?? [];
|
||||
for (const parentDoc of parentDocuments) {
|
||||
await userMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.fetchDocuments({ force: true });
|
||||
await groupMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.fetchDocuments({ force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +230,17 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
|
||||
const document = documents.get(event.id);
|
||||
const parentDocuments = document?.parentDocuments ?? [];
|
||||
for (const parentDoc of parentDocuments) {
|
||||
userMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.updateDocument(event);
|
||||
groupMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.updateDocument(event);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -233,6 +253,17 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(event.id);
|
||||
}
|
||||
|
||||
const document = documents.get(event.id);
|
||||
const parentDocuments = document?.parentDocuments ?? [];
|
||||
for (const parentDoc of parentDocuments) {
|
||||
userMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.removeDocument(event.id);
|
||||
groupMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -250,6 +281,17 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
userMemberships.orderedData
|
||||
.filter((m) => m.documentId === event.id)
|
||||
.forEach((m) => userMemberships.remove(m.id));
|
||||
|
||||
const document = documents.get(event.id);
|
||||
const parentDocuments = document?.parentDocuments ?? [];
|
||||
for (const parentDoc of parentDocuments) {
|
||||
userMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.removeDocument(event.id);
|
||||
groupMemberships
|
||||
.find({ documentId: parentDoc.id })
|
||||
?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -260,6 +302,56 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.move",
|
||||
action(async (event: WebsocketDocumentMovedEvent) => {
|
||||
if (event.documentId) {
|
||||
const document = await documents.fetch(event.documentId, {
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Refresh documents structure in case this document (or) any of its ancestors is shared to the user.
|
||||
const documentIdsToRefresh = [
|
||||
event.documentId,
|
||||
...document.parentDocuments.map((doc) => doc.id),
|
||||
];
|
||||
|
||||
for (const documentId of documentIdsToRefresh) {
|
||||
await userMemberships
|
||||
.find({ documentId })
|
||||
?.fetchDocuments({ force: true });
|
||||
await groupMemberships
|
||||
.find({ documentId })
|
||||
?.fetchDocuments({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.prevParentDocumentId) {
|
||||
const prevDocument = documents.get(event.prevParentDocumentId);
|
||||
|
||||
// Refresh documents structure in case the previous parent document (or) any of its ancestors is shared to the user.
|
||||
const documentIdsToRefresh = [
|
||||
event.prevParentDocumentId,
|
||||
...(prevDocument?.parentDocuments.map((doc) => doc.id) ?? []),
|
||||
];
|
||||
|
||||
for (const documentId of documentIdsToRefresh) {
|
||||
await userMemberships
|
||||
.find({ documentId })
|
||||
?.fetchDocuments({ force: true });
|
||||
await groupMemberships
|
||||
.find({ documentId })
|
||||
?.fetchDocuments({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
await collection?.fetchDocuments({ force: true });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.add_user",
|
||||
async (event: PartialExcept<UserMembership, "id">) => {
|
||||
|
||||
@@ -617,6 +617,15 @@ export default class Document extends ArchivableModel {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get parentDocuments(): Document[] {
|
||||
if (!this.parentDocument) {
|
||||
return [];
|
||||
}
|
||||
const parentDocs = this.parentDocument.parentDocuments;
|
||||
return [...parentDocs, this.parentDocument];
|
||||
}
|
||||
|
||||
@computed
|
||||
get asNavigationNode(): NavigationNode {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { observable } from "mobx";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import invariant from "invariant";
|
||||
import { action, observable, runInAction } from "mobx";
|
||||
import {
|
||||
CollectionPermission,
|
||||
DocumentPermission,
|
||||
NavigationNode,
|
||||
} from "@shared/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
@@ -13,6 +19,8 @@ import Relation from "./decorators/Relation";
|
||||
class GroupMembership extends Model {
|
||||
static modelName = "GroupMembership";
|
||||
|
||||
private isFetching = false;
|
||||
|
||||
/** The group ID that this membership is granted to. */
|
||||
groupId: string;
|
||||
|
||||
@@ -45,6 +53,89 @@ class GroupMembership extends Model {
|
||||
@observable
|
||||
permission: CollectionPermission | DocumentPermission;
|
||||
|
||||
/** The sub-documents structure, in case this membership provides access to a document. */
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
|
||||
/**
|
||||
* Fetches the sub-documents structure, in case this membership provides access to a document.
|
||||
*/
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (!this.documentId || this.isFetching) {
|
||||
return;
|
||||
}
|
||||
if (this.documents && options?.force !== true) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.isFetching = true;
|
||||
const res = await client.post("/documents.child_documents", {
|
||||
id: this.documentId,
|
||||
});
|
||||
invariant(res?.data, "Data should be available");
|
||||
|
||||
runInAction("GroupMembership#fetchSubDocuments", () => {
|
||||
this.documents = res.data;
|
||||
});
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the membership in memory.
|
||||
* Does not update the document in the database.
|
||||
*
|
||||
* @param document The document properties stored in the membership
|
||||
*/
|
||||
@action
|
||||
updateDocument(
|
||||
document: Pick<Document, "id" | "title" | "url" | "color" | "icon">
|
||||
) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
node.color = document.color ?? undefined;
|
||||
node.icon = document.icon ?? undefined;
|
||||
node.title = document.title;
|
||||
node.url = document.url;
|
||||
} else {
|
||||
travelNodes(node.children);
|
||||
}
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the document identified by the given id in the membership in memory.
|
||||
* Does not remove the document from the database.
|
||||
*
|
||||
* @param documentId The id of the document to remove.
|
||||
*/
|
||||
@action
|
||||
removeDocument(documentId: string) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.documents = this.documents.filter(function f(node): boolean {
|
||||
if (node.id === documentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children = node.children.filter(f);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import invariant from "invariant";
|
||||
import { action, observable, runInAction } from "mobx";
|
||||
import { DocumentPermission, NavigationNode } from "@shared/types";
|
||||
import type UserMembershipsStore from "~/stores/UserMembershipsStore";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import Model from "./base/Model";
|
||||
@@ -11,6 +13,8 @@ import Relation from "./decorators/Relation";
|
||||
class UserMembership extends Model {
|
||||
static modelName = "UserMembership";
|
||||
|
||||
private isFetching = false;
|
||||
|
||||
/** The sort order of the membership (In users sidebar) */
|
||||
@Field
|
||||
@observable
|
||||
@@ -48,6 +52,10 @@ class UserMembership extends Model {
|
||||
/** The user ID that created this membership. */
|
||||
createdById: string;
|
||||
|
||||
/** The sub-documents structure, in case this membership provides access to a document. */
|
||||
@observable
|
||||
documents?: NavigationNode[];
|
||||
|
||||
store: UserMembershipsStore;
|
||||
|
||||
/**
|
||||
@@ -72,6 +80,85 @@ class UserMembership extends Model {
|
||||
return memberships[index + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the sub-documents structure, in case this membership provides access to a document.
|
||||
*/
|
||||
fetchDocuments = async (options?: { force: boolean }) => {
|
||||
if (this.isFetching || !this.documentId) {
|
||||
return;
|
||||
}
|
||||
if (this.documents && options?.force !== true) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.isFetching = true;
|
||||
const res = await client.post("/documents.child_documents", {
|
||||
id: this.documentId,
|
||||
});
|
||||
invariant(res?.data, "Data should be available");
|
||||
|
||||
runInAction("UserMembership#fetchSubDocuments", () => {
|
||||
this.documents = res.data;
|
||||
});
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the membership in memory.
|
||||
* Does not update the document in the database.
|
||||
*
|
||||
* @param document The document properties stored in the membership
|
||||
*/
|
||||
@action
|
||||
updateDocument(
|
||||
document: Pick<Document, "id" | "title" | "url" | "color" | "icon">
|
||||
) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
node.color = document.color ?? undefined;
|
||||
node.icon = document.icon ?? undefined;
|
||||
node.title = document.title;
|
||||
node.url = document.url;
|
||||
} else {
|
||||
travelNodes(node.children);
|
||||
}
|
||||
});
|
||||
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the document identified by the given id in the membership in memory.
|
||||
* Does not remove the document from the database.
|
||||
*
|
||||
* @param documentId The id of the document to remove.
|
||||
*/
|
||||
@action
|
||||
removeDocument(documentId: string) {
|
||||
if (!this.documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.documents = this.documents.filter(function f(node): boolean {
|
||||
if (node.id === documentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children = node.children.filter(f);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterRemove
|
||||
|
||||
@@ -188,11 +188,18 @@ export type WebsocketCollectionUpdateIndexEvent = {
|
||||
index: string;
|
||||
};
|
||||
|
||||
export type WebsocketDocumentMovedEvent = {
|
||||
documentId?: string;
|
||||
prevParentDocumentId?: string;
|
||||
collectionId?: string;
|
||||
};
|
||||
|
||||
export type WebsocketEvent =
|
||||
| PartialExcept<Pin, "id">
|
||||
| PartialExcept<Star, "id">
|
||||
| PartialExcept<FileOperation, "id">
|
||||
| PartialExcept<UserMembership, "id">
|
||||
| WebsocketDocumentMovedEvent
|
||||
| WebsocketCollectionUpdateIndexEvent
|
||||
| WebsocketEntityDeletedEvent
|
||||
| WebsocketEntitiesEvent;
|
||||
|
||||
@@ -46,6 +46,7 @@ async function documentMover({
|
||||
transaction,
|
||||
}: Props): Promise<Result> {
|
||||
const collectionChanged = collectionId !== document.collectionId;
|
||||
const prevParentDocumentId = document.parentDocumentId;
|
||||
const previousCollectionId = document.collectionId;
|
||||
const result: Result = {
|
||||
collections: [],
|
||||
@@ -273,9 +274,11 @@ async function documentMover({
|
||||
collectionId,
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
collectionIds: result.collections.map((c) => c.id),
|
||||
documentIds: result.documents.map((d) => d.id),
|
||||
prevParentDocumentId,
|
||||
parentDocumentChanged:
|
||||
prevParentDocumentId !== document.parentDocumentId,
|
||||
},
|
||||
ip,
|
||||
},
|
||||
|
||||
@@ -146,26 +146,38 @@ export default class WebsocketsProcessor {
|
||||
},
|
||||
paranoid: false,
|
||||
});
|
||||
documents.forEach((document) => {
|
||||
socketio.to(`collection-${document.collectionId}`).emit("entities", {
|
||||
event: event.name,
|
||||
documentIds: [
|
||||
{
|
||||
id: document.id,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
],
|
||||
const prevParentDocument = event.data.prevParentDocumentId
|
||||
? await Document.findByPk(event.data.prevParentDocumentId, {
|
||||
paranoid: false,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
await Promise.all(
|
||||
documents.map(async (document) => {
|
||||
const channels = await this.getDocumentEventChannels(
|
||||
event,
|
||||
document
|
||||
);
|
||||
socketio.to(channels).emit(event.name, {
|
||||
documentId: document.id,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (prevParentDocument && event.data.parentDocumentChanged) {
|
||||
const channels = await this.getDocumentEventChannels(
|
||||
event,
|
||||
prevParentDocument
|
||||
);
|
||||
socketio.to(channels).emit(event.name, {
|
||||
prevParentDocumentId: prevParentDocument.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
event.data.collectionIds.forEach((collectionId) => {
|
||||
socketio.to(`collection-${collectionId}`).emit("entities", {
|
||||
event: event.name,
|
||||
collectionIds: [
|
||||
{
|
||||
id: collectionId,
|
||||
},
|
||||
],
|
||||
});
|
||||
socketio
|
||||
.to(`collection-${collectionId}`)
|
||||
.emit(event.name, { collectionId });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -570,6 +570,7 @@ router.post(
|
||||
user,
|
||||
teamId: teamFromCtx?.id,
|
||||
});
|
||||
|
||||
const isPublic = cannot(user, "read", document);
|
||||
const serializedDocument = await presentDocument(ctx, document, {
|
||||
isPublic,
|
||||
@@ -687,6 +688,27 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"documents.child_documents",
|
||||
auth(),
|
||||
validate(T.DocumentsChildrenSchema),
|
||||
async (ctx: APIContext<T.DocumentsChildrenReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
|
||||
authorize(user, "read", document);
|
||||
invariant(document.collectionId, "document not part of a collection");
|
||||
|
||||
const collection = await Collection.findByPk(document.collectionId);
|
||||
const documentTree = collection.getDocumentTree(document.id);
|
||||
|
||||
ctx.body = {
|
||||
data: documentTree?.children,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"documents.export",
|
||||
rateLimiter(RateLimiterStrategy.TwentyFivePerMinute),
|
||||
|
||||
@@ -378,6 +378,12 @@ export const DocumentsUsersSchema = BaseSchema.extend({
|
||||
|
||||
export type DocumentsUsersReq = z.infer<typeof DocumentsUsersSchema>;
|
||||
|
||||
export const DocumentsChildrenSchema = BaseSchema.extend({
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type DocumentsChildrenReq = z.infer<typeof DocumentsChildrenSchema>;
|
||||
|
||||
export const DocumentsAddUserSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Id of the document to which the user is supposed to be added */
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Router from "koa-router";
|
||||
|
||||
import { Op, Sequelize } from "sequelize";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
|
||||
@@ -200,6 +200,8 @@ export type DocumentEvent = BaseEvent<Document> &
|
||||
data: {
|
||||
collectionIds: string[];
|
||||
documentIds: string[];
|
||||
prevParentDocumentId: string | null;
|
||||
parentDocumentChanged: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user