diff --git a/app/hooks/usePolicy.ts b/app/hooks/usePolicy.ts index f674fe2cc..fe1cfecae 100644 --- a/app/hooks/usePolicy.ts +++ b/app/hooks/usePolicy.ts @@ -11,7 +11,6 @@ import useStores from "./useStores"; */ export default function usePolicy(entity?: string | Model | null) { const { policies } = useStores(); - const triggered = React.useRef(false); const entityId = entity ? typeof entity === "string" ? entity @@ -20,12 +19,9 @@ export default function usePolicy(entity?: string | Model | null) { React.useEffect(() => { if (entity && typeof entity !== "string") { - // The policy for this model is missing and we haven't tried to fetch it - // yet, go ahead and do that now. The force flag is needed otherwise the - // network request will be skipped due to the model existing in the store - if (!policies.get(entity.id) && !triggered.current) { - triggered.current = true; - void entity.store.fetch(entity.id, { force: true }); + // The policy for this model is missing, reload relationships for this model. + if (!policies.get(entity.id)) { + void entity.loadRelations(); } } }, [policies, entity]); diff --git a/app/menus/CommentMenu.tsx b/app/menus/CommentMenu.tsx index f487e0d60..131da7c4b 100644 --- a/app/menus/CommentMenu.tsx +++ b/app/menus/CommentMenu.tsx @@ -32,7 +32,7 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) { }); const { documents, dialogs } = useStores(); const { t } = useTranslation(); - const can = usePolicy(comment.id); + const can = usePolicy(comment); const document = documents.get(comment.documentId); const handleDelete = React.useCallback(() => { diff --git a/app/menus/FileOperationMenu.tsx b/app/menus/FileOperationMenu.tsx index 86bc405eb..1cc15bc59 100644 --- a/app/menus/FileOperationMenu.tsx +++ b/app/menus/FileOperationMenu.tsx @@ -16,7 +16,7 @@ type Props = { function FileOperationMenu({ fileOperation, onDelete }: Props) { const { t } = useTranslation(); - const can = usePolicy(fileOperation.id); + const can = usePolicy(fileOperation); const menu = useMenuState({ modal: true, }); diff --git a/app/menus/ShareMenu.tsx b/app/menus/ShareMenu.tsx index 17feb0163..2df6756e5 100644 --- a/app/menus/ShareMenu.tsx +++ b/app/menus/ShareMenu.tsx @@ -24,7 +24,7 @@ function ShareMenu({ share }: Props) { const { shares } = useStores(); const { t } = useTranslation(); const history = useHistory(); - const can = usePolicy(share.id); + const can = usePolicy(share); const handleGoToDocument = React.useCallback( (ev: React.SyntheticEvent) => { diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index 09a0a9b65..5abb30496 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -30,7 +30,7 @@ function UserMenu({ user }: Props) { const menu = useMenuState({ modal: true, }); - const can = usePolicy(user.id); + const can = usePolicy(user); const context = useActionContext({ isContextMenu: true, }); diff --git a/app/models/base/Model.ts b/app/models/base/Model.ts index fcefdb6d3..6159e0492 100644 --- a/app/models/base/Model.ts +++ b/app/models/base/Model.ts @@ -32,26 +32,39 @@ export default abstract class Model { } /** - * Ensures all the defined relations for the model are in memory + * Ensures all the defined relations and policies for the model are in memory. * * @returns A promise that resolves when loading is complete. */ - async loadRelations() { + async loadRelations(): Promise { const relations = getRelationsForModelClass( this.constructor as typeof Model ); if (!relations) { return; } + if (this.loadingRelations) { + return this.loadingRelations; + } + + const promises = []; for (const properties of relations.values()) { const store = this.store.rootStore.getStoreForModelName( properties.relationClassResolver().modelName ); if ("fetch" in store) { - await store.fetch(this[properties.idKey]); + promises.push(store.fetch(this[properties.idKey])); } } + + const policy = this.store.rootStore.policies.get(this.id); + if (!policy) { + promises.push(this.store.fetch(this.id, { force: true })); + } + + this.loadingRelations = Promise.all(promises); + return await this.loadingRelations; } /** @@ -175,4 +188,9 @@ export default abstract class Model { } protected persistedAttributes: Partial = {}; + + /** + * A promise that resolves when all relations have been loaded + */ + private loadingRelations: Promise | undefined; } diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index cec205498..8353e3c6a 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -55,7 +55,7 @@ function CollectionScene() { const id = params.id || ""; const collection: Collection | null | undefined = collections.getByUrl(id) || collections.get(id); - const can = usePolicy(collection?.id || ""); + const can = usePolicy(collection); React.useEffect(() => { setLastVisitedPath(currentPath); diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index d8dfdec38..1bd0c0e1c 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -102,7 +102,7 @@ function SharedDocumentScene(props: Props) { ) ? (searchParams.get("theme") as Theme) : undefined; - const can = usePolicy(response?.document.id ?? ""); + const can = usePolicy(response?.document); const theme = useBuildTheme(response?.team?.customTheme, themeOverride); React.useEffect(() => { diff --git a/app/scenes/Document/components/CommentThread.tsx b/app/scenes/Document/components/CommentThread.tsx index ab00a50eb..ada63bcef 100644 --- a/app/scenes/Document/components/CommentThread.tsx +++ b/app/scenes/Document/components/CommentThread.tsx @@ -71,7 +71,7 @@ function CommentThread({ document, comment: thread, }); - const can = usePolicy(document.id); + const can = usePolicy(document); const commentsInThread = comments .inThread(thread.id) diff --git a/app/scenes/Document/components/Comments.tsx b/app/scenes/Document/components/Comments.tsx index f4667582f..5fd73c133 100644 --- a/app/scenes/Document/components/Comments.tsx +++ b/app/scenes/Document/components/Comments.tsx @@ -23,7 +23,7 @@ function Comments() { const match = useRouteMatch<{ documentSlug: string }>(); const document = documents.getByUrl(match.params.documentSlug); const focusedComment = useFocusedComment(); - const can = usePolicy(document?.id); + const can = usePolicy(document); useKeyDown("Escape", () => document && ui.collapseComments(document?.id)); diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 5cfa9e079..b18e04d30 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -78,7 +78,7 @@ function DataLoader({ match, children }: Props) { const isEditRoute = match.path === matchDocumentEdit || match.path.startsWith(settingsPath()); const isEditing = isEditRoute || !user?.separateEditMode; - const can = usePolicy(document?.id); + const can = usePolicy(document); const location = useLocation(); React.useEffect(() => { diff --git a/app/scenes/Document/components/DocumentMeta.tsx b/app/scenes/Document/components/DocumentMeta.tsx index bda1965d2..1c938d67b 100644 --- a/app/scenes/Document/components/DocumentMeta.tsx +++ b/app/scenes/Document/components/DocumentMeta.tsx @@ -32,7 +32,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) { const totalViewers = documentViews.length; const onlyYou = totalViewers === 1 && documentViews[0].userId; const viewsLoadedOnMount = React.useRef(totalViewers > 0); - const can = usePolicy(document.id); + const can = usePolicy(document); const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade; diff --git a/app/scenes/Document/components/DocumentTitle.tsx b/app/scenes/Document/components/DocumentTitle.tsx index 8d7f4f807..4fd6c356a 100644 --- a/app/scenes/Document/components/DocumentTitle.tsx +++ b/app/scenes/Document/components/DocumentTitle.tsx @@ -73,7 +73,6 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle( const ref = React.useRef(null); const [emojiPickerIsOpen, handleOpen, handleClose] = useBoolean(); const { editor } = useDocumentContext(); - const can = usePolicy(documentId); const handleClick = React.useCallback(() => { diff --git a/app/scenes/Document/components/Editor.tsx b/app/scenes/Document/components/Editor.tsx index 4a24fcfc9..3408b3a61 100644 --- a/app/scenes/Document/components/Editor.tsx +++ b/app/scenes/Document/components/Editor.tsx @@ -78,7 +78,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { multiplayer, ...rest } = props; - const can = usePolicy(document.id); + const can = usePolicy(document); const childRef = React.useRef(null); const focusAtStart = React.useCallback(() => { diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 67019526a..52bcea1bb 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -111,7 +111,7 @@ function DocumentHeader({ }); const { isDeleted, isTemplate } = document; - const can = usePolicy(document?.id); + const can = usePolicy(document); const canToggleEmbeds = team?.documentEmbeds; const toc = ( >(); const buttonRef = React.useRef(null); - const can = usePolicy(share ? share.id : ""); + const can = usePolicy(share); const documentAbilities = usePolicy(document); const collection = document.collectionId ? collections.get(document.collectionId)