Files
outline/app/models/Collection.ts
Tom Moor 1491fc2eb4 fix: Allow dragging shared documents to starred section (#7506)
* fix: Allow dragging shared documents to starred section

* fix: Allow read-only collection drag and drop
fix: Full screen delete modal from drag and drop
2024-09-01 14:19:40 -07:00

368 lines
8.0 KiB
TypeScript

import invariant from "invariant";
import { action, computed, observable, reaction, runInAction } from "mobx";
import {
CollectionPermission,
FileOperationFormat,
NavigationNode,
type ProsemirrorData,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import type CollectionsStore from "~/stores/CollectionsStore";
import Document from "~/models/Document";
import ParanoidModel from "~/models/base/ParanoidModel";
import { client } from "~/utils/ApiClient";
import User from "./User";
import Field from "./decorators/Field";
import { AfterChange } from "./decorators/Lifecycle";
export default class Collection extends ParanoidModel {
static modelName = "Collection";
store: CollectionsStore;
@observable
isSaving: boolean;
isFetching = false;
@Field
@observable
id: string;
/**
* The name of the collection.
*/
@Field
@observable
name: string;
@Field
@observable.shallow
data: ProsemirrorData;
/**
* An icon (or) emoji to use as the collection icon.
*/
@Field
@observable
icon: string;
/**
* The color to use for the collection icon and other highlights.
*/
@Field
@observable
color?: string | null;
/**
* The default permission for workspace users.
*/
@Field
@observable
permission?: CollectionPermission;
/**
* Whether public sharing is enabled for the collection. Note this can also be disabled at the
* workspace level.
*/
@Field
@observable
sharing: boolean;
/**
* The sort index for the collection.
*/
@Field
@observable
index: string;
/**
* The sort field and direction for documents in the collection.
*/
@Field
@observable
sort: {
field: string;
direction: "asc" | "desc";
};
@observable
documents?: NavigationNode[];
/**
* @deprecated Use path instead.
*/
@observable
url: string;
@observable
urlId: string;
constructor(fields: Partial<Collection>, store: CollectionsStore) {
super(fields, store);
const resetDocumentPolicies = () => {
this.store.rootStore.documents
.inCollection(this.id)
.forEach((document) => {
this.store.rootStore.policies.remove(document.id);
});
};
reaction(() => this.permission, resetDocumentPolicies);
reaction(() => this.sharing, resetDocumentPolicies);
}
@computed
get isEmpty(): boolean | undefined {
if (!this.documents) {
return undefined;
}
return (
this.documents.length === 0 &&
this.store.rootStore.documents.inCollection(this.id).length === 0
);
}
/**
* Convenience method to return if a collection is considered private.
* This means that a membership is required to view it rather than just being
* a workspace member.
*
* @returns boolean
*/
get isPrivate(): boolean {
return !this.permission;
}
/**
* Check whether this collection has a description.
*
* @returns boolean
*/
@computed
get hasDescription(): boolean {
return this.data ? !ProsemirrorHelper.isEmptyData(this.data) : false;
}
@computed
get isStarred(): boolean {
return !!this.store.rootStore.stars.orderedData.find(
(star) => star.collectionId === this.id
);
}
@computed
get isManualSort(): boolean {
return this.sort.field === "index";
}
@computed
get sortedDocuments(): NavigationNode[] | undefined {
if (!this.documents) {
return undefined;
}
return sortNavigationNodes(this.documents, this.sort);
}
/**
* The initial letter of the collection name.
*
* @returns string
*/
@computed
get initial() {
return (this.name ? this.name[0] : "?").toUpperCase();
}
@computed
get path() {
return this.url;
}
/**
* Returns users that have been individually given access to the collection.
*
* @returns A list of users that have been given access to the collection.
*/
@computed
get members(): User[] {
return this.store.rootStore.memberships.orderedData
.filter((m) => m.collectionId === this.id)
.map((m) => m.user)
.filter(Boolean);
}
fetchDocuments = async (options?: { force: boolean }) => {
if (this.isFetching) {
return;
}
if (this.documents && options?.force !== true) {
return;
}
try {
this.isFetching = true;
const res = await client.post("/collections.documents", {
id: this.id,
});
invariant(res?.data, "Data should be available");
runInAction("Collection#fetchDocuments", () => {
this.documents = res.data;
});
} finally {
this.isFetching = false;
}
};
/**
* Updates the document identified by the given id in the collection in memory.
* Does not update the document in the database.
*
* @param document The document properties stored in the collection
*/
@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 from the collection 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;
});
}
@action
updateIndex(index: string) {
this.index = index;
}
getDocumentChildren(documentId: string) {
let result: NavigationNode[] = [];
const travelNodes = (nodes: NavigationNode[]) => {
nodes.forEach((node) => {
if (node.id === documentId) {
result = node.children;
return;
}
return travelNodes(node.children);
});
};
if (this.sortedDocuments) {
travelNodes(this.sortedDocuments);
}
return result;
}
pathToDocument(documentId: string) {
let path: NavigationNode[] | undefined = [];
const document = this.store.rootStore.documents.get(documentId);
if (!document) {
return path;
}
const travelNodes = (
nodes: NavigationNode[],
previousPath: NavigationNode[]
) => {
nodes.forEach((node) => {
const newPath = [...previousPath, node];
if (node.id === documentId) {
path = newPath;
return;
}
if (
document.parentDocumentId &&
node.id === document.parentDocumentId
) {
path = [...newPath, document.asNavigationNode];
return;
}
return travelNodes(node.children, newPath);
});
};
if (this.documents) {
travelNodes(this.documents, path);
}
return path;
}
@action
star = async (index?: string) => this.store.star(this, index);
@action
unstar = async () => this.store.unstar(this);
export = (format: FileOperationFormat, includeAttachments: boolean) =>
client.post("/collections.export", {
id: this.id,
format,
includeAttachments,
});
// hooks
@AfterChange
static removePolicies(
model: Collection,
previousAttributes: Partial<Collection>
) {
if (previousAttributes && model.sharing !== previousAttributes?.sharing) {
const { documents, policies } = model.store.rootStore;
documents.inCollection(model.id).forEach((document) => {
policies.remove(document.id);
});
}
}
}