Files
outline/app/stores/SharesStore.ts
T
Tom Moor 0139b91b5d chore: Replace lodash with es-toolkit (#12281)
* chore: Replace lodash with es-toolkit

Migrate all direct lodash imports to es-toolkit/compat for a smaller,
faster, lodash-compatible utility library. Transitive lodash usage from
other packages remains unchanged.

* fix: Restore isPlainObject semantics in CanCan policy

The lodash migration aliased `isObject` to `lodash/isPlainObject` and
the codemod incorrectly mapped the local name to es-toolkit's `isObject`,
which also returns true for arrays and functions. This caused condition
objects in policy definitions to be skipped, breaking authorization
checks across the codebase.

* fix: Restore unicode-aware length counting in validators

es-toolkit/compat's size() returns string.length, while lodash's _.size()
counts unicode code points. Switch to [...value].length to preserve the
previous behavior so multi-byte characters like emoji count as one.
2026-05-06 21:03:47 -04:00

173 lines
4.3 KiB
TypeScript

import invariant from "invariant";
import { filter, find, isUndefined, orderBy } from "es-toolkit/compat";
import { action, computed, observable } from "mobx";
import type { NavigationNode, PublicTeam } from "@shared/types";
import type Document from "~/models/Document";
import Share from "~/models/Share";
import type { PartialExcept } from "~/types";
import { client } from "~/utils/ApiClient";
import type RootStore from "./RootStore";
import Store, { RPCAction } from "./base/Store";
export default class SharesStore extends Store<Share> {
actions = [
RPCAction.Info,
RPCAction.List,
RPCAction.Create,
RPCAction.Update,
];
@observable
sharedCache: Map<
string,
{ sharedTree: NavigationNode | null; team: PublicTeam } | undefined
> = new Map();
constructor(rootStore: RootStore) {
super(rootStore, Share);
}
@computed
get orderedData(): Share[] {
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
}
@computed
get published(): Share[] {
return filter(this.orderedData, (share) => share.published);
}
@action
revoke = async (share: Share) => {
await client.post("/shares.revoke", {
id: share.id,
});
this.remove(share.id);
};
@action
async create(
params:
| (PartialExcept<Share, "collectionId"> & { type: "collection" })
| (PartialExcept<Share, "documentId"> & { type: "document" })
): Promise<Share> {
const item =
params.type === "collection"
? this.getByCollectionId(params.collectionId)
: this.getByDocumentId(params.documentId);
if (item) {
return item;
}
return super.create(params);
}
@action
async fetch(id: string) {
const share = this.get(id);
const cache = this.sharedCache.get(id);
if (share && cache) {
return share;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, {
id,
});
invariant(res?.data, "Data should be available");
res.data.shares.map(this.add);
if (res.data.collection) {
this.rootStore.collections.add(res.data.collection);
}
if (res.data.document) {
this.rootStore.documents.add(res.data.document);
}
this.sharedCache.set(id, {
sharedTree: res.data.sharedTree,
team: res.data.team,
});
this.addPolicies(res.policies);
return this.data.get(id)!;
} finally {
this.isFetching = false;
}
}
@action
async fetchOne(params: { documentId: string } | { collectionId: string }) {
const share =
"collectionId" in params
? this.getByCollectionId(params.collectionId)
: this.getByDocumentId(params.documentId);
if (share) {
return share;
}
this.isFetching = true;
try {
const res = await client.post(`/${this.apiEndpoint}.info`, params);
if (isUndefined(res)) {
return;
}
invariant(res?.data, "Data should be available");
this.addPolicies(res.policies);
return res.data.shares.map(this.add);
} finally {
this.isFetching = false;
}
}
getByDocumentParents = (document: Document): Share | undefined => {
const collectionShare = document.collectionId
? this.getByCollectionId(document.collectionId)
: undefined;
if (collectionShare?.published) {
return collectionShare;
}
const collection = document.collectionId
? this.rootStore.collections.get(document.collectionId)
: undefined;
if (!collection) {
return;
}
const parentIds = collection
.pathToDocument(document.id)
.slice(0, -1)
.map((p) => p.id);
for (const parentId of parentIds) {
const share = this.getByDocumentId(parentId);
if (share?.includeChildDocuments && share.published) {
return share;
}
}
return undefined;
};
getByCollectionId = (collectionId: string): Share | null | undefined =>
find(this.orderedData, (share) => share.collectionId === collectionId);
getByDocumentId = (documentId: string): Share | null | undefined =>
find(this.orderedData, (share) => share.documentId === documentId);
get(id: string): Share | undefined {
return id
? (this.data.get(id) ??
this.orderedData.find((share) => id.endsWith(share.urlId)))
: undefined;
}
}