diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index f12495dec..0d6ca28a4 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -254,9 +254,7 @@ class WebsocketProvider extends React.Component { }); this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => { - comments.inThread(event.modelId).forEach((comment) => { - comments.remove(comment.id); - }); + comments.remove(event.modelId); }); this.socket.on("groups.create", (event: PartialWithId) => { diff --git a/app/models/Comment.ts b/app/models/Comment.ts index 5e3707886..bfcda0cfa 100644 --- a/app/models/Comment.ts +++ b/app/models/Comment.ts @@ -34,6 +34,12 @@ class Comment extends Model { @observable parentCommentId: string; + /** + * The comment that this comment is a reply to. + */ + @Relation(() => Comment, { onDelete: "cascade" }) + parentComment?: Comment; + /** * The document to which this comment belongs. */ diff --git a/app/models/Notification.ts b/app/models/Notification.ts index a3a27e2e4..e4a99b674 100644 --- a/app/models/Notification.ts +++ b/app/models/Notification.ts @@ -47,7 +47,7 @@ class Notification extends Model { /** * The document that the notification is associated with. */ - @Relation(() => Document) + @Relation(() => Document, { onDelete: "cascade" }) document?: Document; /** @@ -58,7 +58,7 @@ class Notification extends Model { /** * The comment that the notification is associated with. */ - @Relation(() => Comment) + @Relation(() => Comment, { onDelete: "cascade" }) comment?: Comment; /** diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index dba1635c4..147fac3c2 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -95,12 +95,12 @@ export default abstract class Model { */ toAPI = (): Record => { const fields = getFieldsForModel(this); - return pick(this, fields) || []; + return pick(this, fields); }; /** * Returns a plain object representation of all the properties on the model - * overrides the inbuilt toJSON method to avoid attempting to serialize store + * overrides the native toJSON method to avoid attempting to serialize store * * @returns {Record} */ diff --git a/app/models/decorators/Field.ts b/app/models/decorators/Field.ts index f62e57c0a..5163b7851 100644 --- a/app/models/decorators/Field.ts +++ b/app/models/decorators/Field.ts @@ -1,9 +1,9 @@ import type Model from "../base/Model"; -const fields = new Map(); +const fields = new Map(); export const getFieldsForModel = (target: Model) => - fields.get(target.constructor.name); + fields.get(target.constructor.name) ?? []; /** * A decorator that records this key as a serializable field on the model. @@ -14,7 +14,10 @@ export const getFieldsForModel = (target: Model) => */ const Field = (target: any, propertyKey: keyof T) => { const className = target.constructor.name; - fields.set(className, [...(fields.get(className) || []), propertyKey]); + fields.set(className, [ + ...(fields.get(className) || []), + propertyKey as string, + ]); }; export default Field; diff --git a/app/models/decorators/Relation.ts b/app/models/decorators/Relation.ts index adad623e1..ec8dec8b7 100644 --- a/app/models/decorators/Relation.ts +++ b/app/models/decorators/Relation.ts @@ -4,6 +4,47 @@ import type Model from "../base/Model"; type RelationOptions = { /** Whether this relation is required */ required?: boolean; + /** Behavior of relationship on deletion */ + onDelete: "cascade" | "null" | "ignore"; +}; + +type RelationProperties = { + /** The name of the property on the model that stores the ID of the relation */ + idKey: string; + /** A function that returns the class of the relation */ + relationClassResolver: () => typeof Model; + /** Options for the relation */ + options: RelationOptions; +}; + +type InverseRelationProperties = RelationProperties & { + /** The name of the model class that owns this relation */ + modelName: string; +}; + +const relations = new Map>(new Map()); + +/** + * Returns the inverse relation properties for the given model class. + * + * @param targetClass The model class to get inverse relations for. + * @returns A map of inverse relation properties keyed by the property name. + */ +export const getInverseRelationsForModelClass = (targetClass: typeof Model) => { + const inverseRelations = new Map(); + + relations.forEach((relation, modelName) => { + relation.forEach((properties, propertyName) => { + if (properties.relationClassResolver().name === targetClass.name) { + inverseRelations.set(propertyName, { + ...properties, + modelName, + }); + } + }); + }); + + return inverseRelations; }; /** @@ -20,8 +61,19 @@ export default function Relation( ) { return function (target: any, propertyKey: string) { const idKey = `${String(propertyKey)}Id`; - const relationClass = classResolver(); - const relationClassName = relationClass.name; + + // If the relation has options provided then register them in a map for later lookup. We can use + // this to determine how to update relations when a model is deleted. + if (options) { + const configForClass = + relations.get(target.constructor.name) || new Map(); + configForClass.set(propertyKey, { + options, + relationClassResolver: classResolver, + idKey, + }); + relations.set(target.constructor.name, configForClass); + } Object.defineProperty(target, propertyKey, { get() { @@ -31,6 +83,7 @@ export default function Relation( return undefined; } + const relationClassName = classResolver().name; const store = this.store.rootStore[`${relationClassName.toLowerCase()}s`]; invariant(store, `Store for ${relationClassName} not found`); @@ -41,6 +94,7 @@ export default function Relation( this[idKey] = newValue ? newValue.id : undefined; if (newValue) { + const relationClassName = classResolver().name; const store = this.store.rootStore[`${relationClassName.toLowerCase()}s`]; invariant(store, `Store for ${relationClassName} not found`); diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts index dcc305b53..e8ac79808 100644 --- a/app/stores/base/Store.ts +++ b/app/stores/base/Store.ts @@ -6,6 +6,7 @@ import { Class } from "utility-types"; import RootStore from "~/stores/RootStore"; import Policy from "~/models/Policy"; import Model from "~/models/base/Model"; +import { getInverseRelationsForModelClass } from "~/models/decorators/Relation"; import { PaginationParams, PartialWithId } from "~/types"; import { client } from "~/utils/ApiClient"; import { AuthorizationError, NotFoundError } from "~/utils/errors"; @@ -99,6 +100,26 @@ export default abstract class Store { @action remove(id: string): void { + const inverseRelations = getInverseRelationsForModelClass(this.model); + + inverseRelations.forEach((relation) => { + // TODO: Need a better way to get the store for a given model name. + const store = this.rootStore[`${relation.modelName.toLowerCase()}s`]; + const items = store.orderedData.filter( + (item: Model) => item[relation.idKey] === id + ); + + if (relation.options.onDelete === "cascade") { + items.forEach((item: Model) => store.remove(item.id)); + } + + if (relation.options.onDelete === "null") { + items.forEach((item: Model) => { + item[relation.idKey] = null; + }); + } + }); + this.data.delete(id); }