chore: Refactor of activeDocumentId (#11144)

* wip: Refactor of activeDocumentId

* Remove legacy usage from definitions/collections
This commit is contained in:
Tom Moor
2026-02-04 19:37:34 -05:00
committed by GitHub
parent 44eaf97ea4
commit 30d1900f41
4 changed files with 267 additions and 179 deletions
+112 -166
View File
@@ -20,7 +20,7 @@ import {
UnsubscribeIcon,
} from "outline-icons";
import { toast } from "sonner";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
@@ -96,11 +96,11 @@ export const editCollection = createAction({
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -109,7 +109,7 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
collectionId={collection.id}
/>
),
});
@@ -122,14 +122,10 @@ export const editCollectionPermissions = createAction({
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -152,15 +148,16 @@ export const importDocument = createAction({
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
@@ -170,15 +167,10 @@ export const importDocument = createAction({
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
} catch (err) {
toast.error(err.message);
}
@@ -191,11 +183,10 @@ export const importDocument = createAction({
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
@@ -213,15 +204,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -233,15 +224,15 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "title",
@@ -253,12 +244,12 @@ export const sortCollection = createActionWithChildren({
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return collection?.save({
sort: {
field: "index",
@@ -275,22 +266,19 @@ export const searchInCollection = createInternalLinkAction({
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(activeCollectionId).readDocument;
return stores.policies.abilities(collection.id).readDocument;
},
to: ({ activeCollectionId, sidebarContext }) => {
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
collectionId: collection?.id,
}).split("?");
return {
@@ -307,23 +295,22 @@ export const starCollection = createAction({
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
!collection.isStarred && stores.policies.abilities(collection.id).star
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
await collection.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -334,22 +321,18 @@ export const unstarCollection = createAction({
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
);
},
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
await collection?.unstar();
},
});
@@ -359,28 +342,25 @@ export const subscribeCollection = createAction({
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
await collection.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
@@ -390,28 +370,25 @@ export const unsubscribeCollection = createAction({
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
);
},
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
await collection.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
@@ -421,23 +398,15 @@ export const archiveCollection = createAction({
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
dialogs.openModal({
stores.dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -462,17 +431,10 @@ export const restoreCollection = createAction({
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -488,18 +450,10 @@ export const deleteCollection = createAction({
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -521,18 +475,10 @@ export const exportCollection = createAction({
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).export;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
return;
}
@@ -555,13 +501,13 @@ export const createDocument = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
return {
pathname,
@@ -577,13 +523,13 @@ export const createTemplate = createInternalLinkAction({
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newTemplatePath(collection?.id).split("?");
return {
pathname,
+25
View File
@@ -4,6 +4,8 @@ import React, { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import useStores from "~/hooks/useStores";
import type Model from "~/models/base/Model";
import type Policy from "~/models/Policy";
import type { ActionContext as ActionContextType } from "~/types";
export const ActionContext = createContext<ActionContextType | undefined>(
@@ -49,8 +51,31 @@ export const ActionContextProvider = observer(function ActionContextProvider_({
isMenu: false,
isCommandBar: false,
isButton: false,
// Legacy (backward compatibility)
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
activeDocumentId: stores.ui.activeDocumentId ?? undefined,
// New API
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
): T[] => stores.ui.getActiveModels<T>(modelClass),
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined => stores.ui.getActiveModels<T>(modelClass)[0],
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
): Policy[] =>
stores.ui
.getActiveModels<T>(modelClass)
.map((node) => stores.policies.get(node.id))
.filter((policy): policy is Policy => policy !== undefined),
isModelActive: (model: Model): boolean => stores.ui.isModelActive(model),
activeModels: stores.ui.activeModels,
currentUserId: stores.auth.user?.id,
currentTeamId: stores.auth.team?.id,
location,
+112 -13
View File
@@ -2,7 +2,9 @@ import { action, computed, observable } from "mobx";
import { flushSync } from "react-dom";
import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import type Document from "~/models/Document";
import type Model from "~/models/base/Model";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { startViewTransition } from "~/utils/viewTransition";
import type RootStore from "./RootStore";
@@ -52,10 +54,7 @@ class UiStore {
systemTheme: SystemTheme;
@observable
activeDocumentId: string | undefined;
@observable
activeCollectionId?: string | null;
activeModels = new Set<Model>();
@observable
observingUserId: string | undefined;
@@ -150,6 +149,86 @@ class UiStore {
});
}
/**
* Add a model instance to the active set.
*
* @param model the model instance to add.
*/
@action
addActiveModel = (model: Model): void => {
this.activeModels.add(model);
};
/**
* Remove a model instance from the active set.
*
* @param model the model instance to remove.
*/
@action
removeActiveModel = (model: Model): void => {
this.activeModels.delete(model);
};
/**
* Get all active models of a specific type.
*
* @param modelClass the model class to filter by.
* @returns array of active models of the specified type.
*/
getActiveModels<T extends Model>(modelClass: new (...args: any[]) => T): T[] {
return Array.from(this.activeModels).filter(
(model) => model.constructor === modelClass
) as T[];
}
/**
* Check if a model instance is in the active set.
*
* @param model the model instance to check.
* @returns true if the model is active.
*/
isModelActive(model: Model): boolean {
return this.activeModels.has(model);
}
/**
* Clear all active models, or only models of a specific type.
*
* @param modelClass optional model class to filter by.
*/
@action
clearActiveModels(modelClass?: new (...args: any[]) => Model): void {
if (modelClass) {
const modelsToRemove = this.getActiveModels(modelClass);
modelsToRemove.forEach((model) => this.activeModels.delete(model));
} else {
this.activeModels.clear();
}
}
/**
* Get the most recently added model of a specific type (primary).
*
* @param modelClass the model class to filter by.
* @returns the most recently added model of the specified type.
*/
getPrimaryActiveModel<T extends Model>(
modelClass: new (...args: any[]) => T
): T | undefined {
const models = this.getActiveModels<T>(modelClass);
return models[models.length - 1];
}
@computed
get activeDocumentId(): string | undefined {
return this.getPrimaryActiveModel<Document>(Document)?.id;
}
@computed
get activeCollectionId(): string | undefined {
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
}
@action
setTheme = (theme: Theme) => {
startViewTransition(() => {
@@ -173,17 +252,28 @@ class UiStore {
@action
setActiveDocument = (document: Document | string): void => {
let model: Document | undefined;
if (typeof document === "string") {
this.activeDocumentId = document;
this.observingUserId = undefined;
model = this.rootStore.documents.get(document);
} else {
model = document;
}
if (!model) {
return;
}
this.activeDocumentId = document.id;
this.clearActiveModels(Document);
this.addActiveModel(model);
this.observingUserId = undefined;
if (document.isActive) {
this.activeCollectionId = document.collectionId;
if (model.isActive && model.collectionId) {
const collection = this.rootStore.collections.get(model.collectionId);
if (collection) {
this.clearActiveModels(Collection);
this.addActiveModel(collection);
}
}
};
@@ -203,7 +293,16 @@ class UiStore {
@action
setActiveCollection = (collectionId: string | undefined): void => {
this.activeCollectionId = collectionId;
if (collectionId === undefined || collectionId === null) {
this.clearActiveModels(Collection);
return;
}
const model = this.rootStore.collections.get(collectionId);
if (model) {
this.clearActiveModels(Collection);
this.addActiveModel(model);
}
};
@action
@@ -213,12 +312,12 @@ class UiStore {
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.clearActiveModels(Document);
this.observingUserId = undefined;
// Unset when navigating away from a document (e.g. to another document, home, settings, etc.)
// Next document's onMount will set the right activeCollectionId.
this.activeCollectionId = undefined;
this.clearActiveModels(Collection);
};
@action
+18
View File
@@ -8,12 +8,14 @@ import type {
} from "@shared/types";
import type RootStore from "~/stores/RootStore";
import type { SidebarContextType } from "./components/Sidebar/components/SidebarContext";
import type Model from "./models/base/Model";
import type Document from "./models/Document";
import type FileOperation from "./models/FileOperation";
import type Pin from "./models/Pin";
import type Star from "./models/Star";
import type User from "./models/User";
import type UserMembership from "./models/UserMembership";
import type Policy from "./models/Policy";
export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
Required<Pick<T, K>>;
@@ -104,8 +106,24 @@ export type ActionContext = {
isCommandBar: boolean;
isButton: boolean;
sidebarContext?: SidebarContextType;
// Legacy (backward compatibility) - returns primary active model's ID
activeCollectionId?: string | undefined;
activeDocumentId: string | undefined;
// New API - work directly with Model instances
getActiveModels: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T[];
getActiveModel: <T extends Model>(
modelClass: new (...args: any[]) => T
) => T | undefined;
getActivePolicies: <T extends Model>(
modelClass: new (...args: any[]) => T
) => Policy[];
isModelActive: (model: Model) => boolean;
activeModels: ReadonlySet<Model>;
currentUserId: string | undefined;
currentTeamId: string | undefined;
location: Location;