mirror of
https://github.com/outline/outline.git
synced 2026-01-07 11:40:08 -06:00
* 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
368 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|
|
}
|