Add registering of client-side relationship properties (#5936)
This commit is contained in:
@@ -254,9 +254,7 @@ class WebsocketProvider extends React.Component<Props> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
|
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||||
comments.inThread(event.modelId).forEach((comment) => {
|
comments.remove(event.modelId);
|
||||||
comments.remove(comment.id);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class Comment extends Model {
|
|||||||
@observable
|
@observable
|
||||||
parentCommentId: string;
|
parentCommentId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comment that this comment is a reply to.
|
||||||
|
*/
|
||||||
|
@Relation(() => Comment, { onDelete: "cascade" })
|
||||||
|
parentComment?: Comment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The document to which this comment belongs.
|
* The document to which this comment belongs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class Notification extends Model {
|
|||||||
/**
|
/**
|
||||||
* The document that the notification is associated with.
|
* The document that the notification is associated with.
|
||||||
*/
|
*/
|
||||||
@Relation(() => Document)
|
@Relation(() => Document, { onDelete: "cascade" })
|
||||||
document?: Document;
|
document?: Document;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +58,7 @@ class Notification extends Model {
|
|||||||
/**
|
/**
|
||||||
* The comment that the notification is associated with.
|
* The comment that the notification is associated with.
|
||||||
*/
|
*/
|
||||||
@Relation(() => Comment)
|
@Relation(() => Comment, { onDelete: "cascade" })
|
||||||
comment?: Comment;
|
comment?: Comment;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,12 +95,12 @@ export default abstract class Model {
|
|||||||
*/
|
*/
|
||||||
toAPI = (): Record<string, any> => {
|
toAPI = (): Record<string, any> => {
|
||||||
const fields = getFieldsForModel(this);
|
const fields = getFieldsForModel(this);
|
||||||
return pick(this, fields) || [];
|
return pick(this, fields);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a plain object representation of all the properties on the model
|
* 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<string, any>}
|
* @returns {Record<string, any>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type Model from "../base/Model";
|
import type Model from "../base/Model";
|
||||||
|
|
||||||
const fields = new Map();
|
const fields = new Map<string, string[]>();
|
||||||
|
|
||||||
export const getFieldsForModel = (target: Model) =>
|
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.
|
* A decorator that records this key as a serializable field on the model.
|
||||||
@@ -14,7 +14,10 @@ export const getFieldsForModel = (target: Model) =>
|
|||||||
*/
|
*/
|
||||||
const Field = <T>(target: any, propertyKey: keyof T) => {
|
const Field = <T>(target: any, propertyKey: keyof T) => {
|
||||||
const className = target.constructor.name;
|
const className = target.constructor.name;
|
||||||
fields.set(className, [...(fields.get(className) || []), propertyKey]);
|
fields.set(className, [
|
||||||
|
...(fields.get(className) || []),
|
||||||
|
propertyKey as string,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Field;
|
export default Field;
|
||||||
|
|||||||
@@ -4,6 +4,47 @@ import type Model from "../base/Model";
|
|||||||
type RelationOptions = {
|
type RelationOptions = {
|
||||||
/** Whether this relation is required */
|
/** Whether this relation is required */
|
||||||
required?: boolean;
|
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<string, Map<string, RelationProperties>>(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<string, InverseRelationProperties>();
|
||||||
|
|
||||||
|
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<T extends typeof Model>(
|
|||||||
) {
|
) {
|
||||||
return function (target: any, propertyKey: string) {
|
return function (target: any, propertyKey: string) {
|
||||||
const idKey = `${String(propertyKey)}Id`;
|
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, {
|
Object.defineProperty(target, propertyKey, {
|
||||||
get() {
|
get() {
|
||||||
@@ -31,6 +83,7 @@ export default function Relation<T extends typeof Model>(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const relationClassName = classResolver().name;
|
||||||
const store =
|
const store =
|
||||||
this.store.rootStore[`${relationClassName.toLowerCase()}s`];
|
this.store.rootStore[`${relationClassName.toLowerCase()}s`];
|
||||||
invariant(store, `Store for ${relationClassName} not found`);
|
invariant(store, `Store for ${relationClassName} not found`);
|
||||||
@@ -41,6 +94,7 @@ export default function Relation<T extends typeof Model>(
|
|||||||
this[idKey] = newValue ? newValue.id : undefined;
|
this[idKey] = newValue ? newValue.id : undefined;
|
||||||
|
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
|
const relationClassName = classResolver().name;
|
||||||
const store =
|
const store =
|
||||||
this.store.rootStore[`${relationClassName.toLowerCase()}s`];
|
this.store.rootStore[`${relationClassName.toLowerCase()}s`];
|
||||||
invariant(store, `Store for ${relationClassName} not found`);
|
invariant(store, `Store for ${relationClassName} not found`);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Class } from "utility-types";
|
|||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
import Policy from "~/models/Policy";
|
import Policy from "~/models/Policy";
|
||||||
import Model from "~/models/base/Model";
|
import Model from "~/models/base/Model";
|
||||||
|
import { getInverseRelationsForModelClass } from "~/models/decorators/Relation";
|
||||||
import { PaginationParams, PartialWithId } from "~/types";
|
import { PaginationParams, PartialWithId } from "~/types";
|
||||||
import { client } from "~/utils/ApiClient";
|
import { client } from "~/utils/ApiClient";
|
||||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||||
@@ -99,6 +100,26 @@ export default abstract class Store<T extends Model> {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
remove(id: string): void {
|
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);
|
this.data.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user