Compare commits

...

2 Commits

Author SHA1 Message Date
hmacr 81b012794f cleanup 2024-10-08 20:21:08 +05:30
hmacr ec1bad11d0 fix: show consistent structure for shared docs 2024-10-08 19:28:00 +05:30
12 changed files with 364 additions and 35 deletions
@@ -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}
/>
+95 -3
View File
@@ -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">) => {
+9
View File
@@ -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 {
+93 -2
View File
@@ -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
+89 -2
View File
@@ -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
+7
View File
@@ -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;
+4 -1
View File
@@ -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,
},
+30 -18
View File
@@ -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;
}
+22
View File
@@ -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),
+6
View File
@@ -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";
+2
View File
@@ -200,6 +200,8 @@ export type DocumentEvent = BaseEvent<Document> &
data: {
collectionIds: string[];
documentIds: string[];
prevParentDocumentId: string | null;
parentDocumentChanged: boolean;
};
}
| {