Merge branch 'main' of github.com:outline/outline

This commit is contained in:
Tom Moor
2023-10-05 21:52:13 -04:00
12 changed files with 140 additions and 62 deletions

View File

@@ -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>) => {

View File

@@ -302,6 +302,7 @@ export class Editor extends React.PureComponent<
public componentWillUnmount(): void { public componentWillUnmount(): void {
window.removeEventListener("theme-changed", this.dispatchThemeChanged); window.removeEventListener("theme-changed", this.dispatchThemeChanged);
this.view.destroy();
this.mutationObserver?.disconnect(); this.mutationObserver?.disconnect();
} }
@@ -349,27 +350,26 @@ export class Editor extends React.PureComponent<
private createNodeViews() { private createNodeViews() {
return this.extensions.extensions return this.extensions.extensions
.filter((extension: ReactNode) => extension.component) .filter((extension: ReactNode) => extension.component)
.reduce((nodeViews, extension: ReactNode) => { .reduce(
const nodeView = ( (nodeViews, extension: ReactNode) => ({
node: ProsemirrorNode,
view: EditorView,
getPos: () => number,
decorations: Decoration[]
) =>
new ComponentView(extension.component, {
editor: this,
extension,
node,
view,
getPos,
decorations,
});
return {
...nodeViews, ...nodeViews,
[extension.name]: nodeView, [extension.name]: (
}; node: ProsemirrorNode,
}, {}); view: EditorView,
getPos: () => number,
decorations: Decoration[]
) =>
new ComponentView(extension.component, {
editor: this,
extension,
node,
view,
getPos,
decorations,
}),
}),
{}
);
} }
private createCommands() { private createCommands() {

View File

@@ -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.
*/ */

View File

@@ -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;
/** /**

View File

@@ -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>}
*/ */

View File

@@ -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;

View File

@@ -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`);

View File

@@ -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);
} }

View File

@@ -171,7 +171,7 @@
"prosemirror-state": "^1.4.3", "prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.3.2", "prosemirror-tables": "^1.3.2",
"prosemirror-transform": "^1.7.3", "prosemirror-transform": "^1.7.3",
"prosemirror-view": "^1.31.3", "prosemirror-view": "^1.32.0",
"query-string": "^7.1.3", "query-string": "^7.1.3",
"quoted-printable": "^1.0.1", "quoted-printable": "^1.0.1",
"randomstring": "1.2.3", "randomstring": "1.2.3",

View File

@@ -29,10 +29,10 @@ const Image = (props: Props) => {
const [loaded, setLoaded] = React.useState(false); const [loaded, setLoaded] = React.useState(false);
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width); const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height); const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
const documentBounds = useComponentSize(props.view.dom);
const containerBounds = useComponentSize( const containerBounds = useComponentSize(
document.body.querySelector("#full-width-container") document.body.querySelector("#full-width-container")
); );
const documentBounds = props.view.dom.getBoundingClientRect();
const maxWidth = layoutClass const maxWidth = layoutClass
? documentBounds.width / 3 ? documentBounds.width / 3
: documentBounds.width; : documentBounds.width;

View File

@@ -14,48 +14,44 @@ const defaultRect = {
export default function useComponentSize( export default function useComponentSize(
element: HTMLElement | null element: HTMLElement | null
): DOMRect | typeof defaultRect { ): DOMRect | typeof defaultRect {
const [size, setSize] = useState(element?.getBoundingClientRect()); const [size, setSize] = useState(() => element?.getBoundingClientRect());
useEffect(() => { useEffect(() => {
const sizeObserver = new ResizeObserver((entries) => { const sizeObserver = new ResizeObserver(() => {
entries.forEach(({ target }) => { element?.dispatchEvent(new CustomEvent("resize"));
const rect = target?.getBoundingClientRect();
setSize((state) =>
state?.width === rect?.width &&
state?.height === rect?.height &&
state?.x === rect?.x &&
state?.y === rect?.y
? state
: rect
);
});
}); });
if (element) { if (element) {
sizeObserver.observe(element); sizeObserver.observe(element);
} }
return () => sizeObserver.disconnect(); return () => sizeObserver.disconnect();
}, [element]); }, [element]);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
const rect = element?.getBoundingClientRect(); setSize((state: DOMRect) => {
setSize((state) => const rect = element?.getBoundingClientRect();
state?.width === rect?.width &&
state?.height === rect?.height && if (
state?.x === rect?.x && rect &&
state?.y === rect?.y Math.round(state.width) === Math.round(rect.width) &&
? state Math.round(state.height) === Math.round(rect.height) &&
: rect Math.round(state.x) === Math.round(rect.x) &&
); Math.round(state.y) === Math.round(rect.y)
) {
return state;
}
return rect;
});
}; };
window.addEventListener("click", handleResize); window.addEventListener("click", handleResize);
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
element?.addEventListener("resize", handleResize);
return () => { return () => {
window.removeEventListener("click", handleResize); window.removeEventListener("click", handleResize);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
element?.removeEventListener("resize", handleResize);
}; };
}); });

View File

@@ -10848,10 +10848,10 @@ prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transfor
dependencies: dependencies:
prosemirror-model "^1.0.0" prosemirror-model "^1.0.0"
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.31.3: prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.32.0:
version "1.31.3" version "1.32.0"
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.31.3.tgz#cfe171c4e50a577526d0235d9ec757cdddf6017d" resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.32.0.tgz#2022538d02932c0901232d1e0430c064f79a8ea2"
integrity sha512-UYDa8WxRFZm0xQLXiPJUVTl6H08Fn0IUVDootA7ZlQwzooqVWnBOXLovJyyTKgws1nprfsPhhlvWgt2jo4ZA6g== integrity sha512-HwW7IWgca6ehiW2PA48H/8yl0TakA0Ms5LgN5Krc97oar7GfjIKE/NocUsLe74Jq4mwyWKUNoBljE8WkXKZwng==
dependencies: dependencies:
prosemirror-model "^1.16.0" prosemirror-model "^1.16.0"
prosemirror-state "^1.0.0" prosemirror-state "^1.0.0"