diff --git a/.changeset/gentle-clouds-invite.md b/.changeset/gentle-clouds-invite.md
new file mode 100644
index 00000000000..476b7cff773
--- /dev/null
+++ b/.changeset/gentle-clouds-invite.md
@@ -0,0 +1,34 @@
+---
+"@dextinity/cms-admin": minor
+"@dextinity/cms-api": minor
+---
+
+Support scopes in `DependentsList` and `DependenciesList`
+
+Entities can be used across scopes, for instance a DAM that is shared between multiple sites. Until now, the links in both lists always pointed to the currently active scope, leading to a wrong or non-existent page. In addition, the lists didn't show which scope an entry belongs to.
+
+The `Dependency` type now has a `scope` field, which is resolved from the entity's `scope` property or its `@ScopedEntity()` decorator (documents are scoped by their page tree node). Both lists use it to link to the entry in its own scope and show a scope column when more than one scope exists.
+
+**Example**
+
+Request the new field in your dependents/dependencies queries:
+
+```diff
+ dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) {
+ nodes {
+ rootGraphqlObjectType
+ rootId
+ rootColumnName
+ jsonPath
+ name
+ secondaryInformation
+ visible
++ scope
+ }
+ totalCount
+ }
+```
+
+Entities using a `@ScopedEntity()` callback or service report no scope, as it cannot be resolved in SQL. Use the field-path string (e.g. `@ScopedEntity("company.scope")`) or the object mapping (e.g. `@ScopedEntity({ companyId: "company.id" })`) variant to make the scope available.
+
+The block index views must be recreated (`npm run console createBlockIndexViews`) for the scope to be available.
diff --git a/demo/admin/src/documents/pages/EditPage.tsx b/demo/admin/src/documents/pages/EditPage.tsx
index 0915adb1981..4b771b82953 100644
--- a/demo/admin/src/documents/pages/EditPage.tsx
+++ b/demo/admin/src/documents/pages/EditPage.tsx
@@ -61,6 +61,7 @@ const pageTreeNodeDependentsQuery = gql`
name
secondaryInformation
visible
+ scope
}
totalCount
}
@@ -88,6 +89,7 @@ const pageTreeNodeDependenciesQuery = gql`
name
secondaryInformation
visible
+ scope
}
totalCount
}
diff --git a/demo/api/schema.gql b/demo/api/schema.gql
index d2bd29e103c..53cf24d07c5 100644
--- a/demo/api/schema.gql
+++ b/demo/api/schema.gql
@@ -547,6 +547,7 @@ type Dependency {
rootColumnName: String!
rootGraphqlObjectType: String!
rootId: String!
+ scope: JSONObject
secondaryInformation: String
targetGraphqlObjectType: String!
targetId: String!
diff --git a/docs/docs/2-core-concepts/7-dependencies/index.md b/docs/docs/2-core-concepts/7-dependencies/index.md
index c1de77a41bd..bdec7e98df2 100644
--- a/docs/docs/2-core-concepts/7-dependencies/index.md
+++ b/docs/docs/2-core-concepts/7-dependencies/index.md
@@ -311,7 +311,13 @@ Each component requires two props:
) {
item: damFile(id: $id) {
id
- dependents(offset: $offset, limit: $limit, forceRefresh: $forceRefresh, filter: $filter, sort: $sort) {
+ dependents(
+ offset: $offset
+ limit: $limit
+ forceRefresh: $forceRefresh
+ filter: $filter
+ sort: $sort
+ ) {
nodes {
rootGraphqlObjectType
rootId
@@ -320,6 +326,7 @@ Each component requires two props:
name
secondaryInformation
visible
+ scope
}
totalCount
}
@@ -333,3 +340,13 @@ Each component requires two props:
```
+
+#### 6. Scopes
+
+Both lists use the `scope` field to link to the entry in the scope it actually belongs to.
+This matters when an entity is used across scopes, for instance a DAM that is shared between multiple sites.
+Make sure to request the field in your query, otherwise the links point to the currently active scope.
+
+The scope is resolved from the entity's `scope` property or its `@ScopedEntity()` decorator (documents are scoped by their page tree node).
+Entities using a `@ScopedEntity()` callback or service report no scope, as it cannot be resolved in SQL.
+Use the field-path string (e.g. `@ScopedEntity("company.scope")`) or the object mapping (e.g. `@ScopedEntity({ companyId: "company.id" })`) variant to make the scope available.
diff --git a/packages/admin/cms-admin/src/contentScope/ContentScopeIndicator.tsx b/packages/admin/cms-admin/src/contentScope/ContentScopeIndicator.tsx
index 5f0c9a0d9ab..89735ac144a 100644
--- a/packages/admin/cms-admin/src/contentScope/ContentScopeIndicator.tsx
+++ b/packages/admin/cms-admin/src/contentScope/ContentScopeIndicator.tsx
@@ -6,10 +6,7 @@ import type { PropsWithChildren, ReactNode } from "react";
import { FormattedMessage } from "react-intl";
import { type ContentScope, useContentScope } from "./Provider";
-
-const capitalizeString = (string: string) => {
- return string.charAt(0).toUpperCase() + string.slice(1);
-};
+import { getContentScopeLabel } from "./utils/getContentScopeLabel";
interface ContentScopeIndicatorProps {
global?: boolean;
@@ -21,20 +18,11 @@ export const ContentScopeIndicator = ({ global = false, scope: passedScope, chil
const { scope: contentScope, values } = useContentScope();
const scope = passedScope ?? contentScope;
- const findLabelForScopePart = (scopePart: keyof ContentScope) => {
- const label = values.find((value) => {
- return value.scope[scopePart] === scope[scopePart];
- })?.label;
- return (label && label[scopePart]) ?? (scope[scopePart] ? capitalizeString(scope[scopePart]) : undefined);
- };
-
let content: ReactNode;
if (global) {
content = ;
} else {
- const scopeParts = Object.keys(scope);
- const scopeLabels = scopeParts.map((scopePart) => findLabelForScopePart(scopePart)).filter((label) => typeof label === "string") as string[];
- content = scopeLabels.join(" / ");
+ content = getContentScopeLabel({ scope, values });
}
return (
diff --git a/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.test.ts b/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.test.ts
new file mode 100644
index 00000000000..a76443a687b
--- /dev/null
+++ b/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import type { ContentScopeValues } from "../Provider";
+import { getContentScopeLabel } from "./getContentScopeLabel";
+
+const values: ContentScopeValues = [
+ { scope: { domain: "main", language: "de" }, label: { domain: "Main Domain", language: "DE" } },
+ { scope: { domain: "secondary", language: "en" }, label: { language: "EN" } },
+];
+
+describe("getContentScopeLabel", () => {
+ it("should use the labels of the content scope values", () => {
+ expect(getContentScopeLabel({ scope: { domain: "main", language: "de" }, values })).toBe("Main Domain / DE");
+ });
+
+ it("should fall back to the capitalized value when no label exists", () => {
+ expect(getContentScopeLabel({ scope: { domain: "secondary", language: "en" }, values })).toBe("Secondary / EN");
+ });
+
+ it("should support incomplete scopes", () => {
+ expect(getContentScopeLabel({ scope: { domain: "main" }, values })).toBe("Main Domain");
+ });
+
+ it("should omit dimensions without a value", () => {
+ expect(getContentScopeLabel({ scope: { domain: "main", language: null }, values })).toBe("Main Domain");
+ });
+});
diff --git a/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.ts b/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.ts
new file mode 100644
index 00000000000..40a7be53122
--- /dev/null
+++ b/packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.ts
@@ -0,0 +1,21 @@
+import type { ContentScope, ContentScopeValues } from "../Provider";
+
+function capitalizeString(string: string) {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+}
+
+/**
+ * Builds a human-readable label for a scope, for instance "Main Domain / English".
+ *
+ * The label of each scope dimension is taken from the matching content scope value, falling back to the capitalized
+ * value itself. Dimensions without a value are omitted, which also supports incomplete scopes.
+ */
+export function getContentScopeLabel({ scope, values }: { scope: ContentScope; values: ContentScopeValues }): string {
+ return Object.keys(scope)
+ .map((dimension) => {
+ const label = values.find((value) => value.scope[dimension] === scope[dimension])?.label;
+ return label?.[dimension] ?? (scope[dimension] ? capitalizeString(scope[dimension]) : undefined);
+ })
+ .filter((label) => typeof label === "string")
+ .join(" / ");
+}
diff --git a/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.test.ts b/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.test.ts
new file mode 100644
index 00000000000..11e2104f6ce
--- /dev/null
+++ b/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { isScopePartOf } from "./isScopePartOf";
+
+describe("isScopePartOf", () => {
+ it("should match an equal scope", () => {
+ expect(isScopePartOf({ domain: "main", language: "de" }, { domain: "main", language: "de" })).toBe(true);
+ });
+
+ it("should match a scope with fewer dimensions", () => {
+ expect(isScopePartOf({ domain: "main" }, { domain: "main", language: "de" })).toBe(true);
+ });
+
+ it("should not match a scope with a differing dimension", () => {
+ expect(isScopePartOf({ domain: "main", language: "en" }, { domain: "main", language: "de" })).toBe(false);
+ });
+
+ it("should not match a scope with an additional dimension", () => {
+ expect(isScopePartOf({ domain: "main", language: "de" }, { domain: "main" })).toBe(false);
+ });
+
+ it("should match an empty scope", () => {
+ expect(isScopePartOf({}, { domain: "main" })).toBe(true);
+ });
+
+ it("should match null values", () => {
+ expect(isScopePartOf({ domain: null }, { domain: null, language: "de" })).toBe(true);
+ });
+});
diff --git a/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.ts b/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.ts
new file mode 100644
index 00000000000..1f5ebf1fe17
--- /dev/null
+++ b/packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.ts
@@ -0,0 +1,13 @@
+import isEqual from "lodash.isequal";
+
+import type { ContentScope } from "../Provider";
+
+/**
+ * Checks whether a (possibly incomplete) scope is contained in another scope.
+ *
+ * Scopes can have fewer dimensions than the content scope, for instance a DAM file that is scoped by domain only while
+ * the content scope consists of domain and language.
+ */
+export function isScopePartOf(scope: ContentScope, otherScope: ContentScope): boolean {
+ return Object.entries(scope).every(([dimension, value]) => isEqual(otherScope[dimension], value));
+}
diff --git a/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts b/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts
index 627fad7ce36..684c81c0218 100644
--- a/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts
+++ b/packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts
@@ -79,6 +79,7 @@ export const damFileDependentsQuery = gql`
name
secondaryInformation
visible
+ scope
}
totalCount
}
diff --git a/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx b/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx
index abb10460840..2b2564a0ac9 100644
--- a/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx
+++ b/packages/admin/cms-admin/src/dependencies/DependenciesList.tsx
@@ -1,4 +1,4 @@
-import { type QueryResult, type TypedDocumentNode, useApolloClient, useQuery } from "@apollo/client";
+import { type QueryResult, type TypedDocumentNode, useQuery } from "@apollo/client";
import {
Alert,
DataGridToolbar,
@@ -15,28 +15,28 @@ import {
useDataGridRemote,
usePersistentColumnState,
} from "@dextinity/admin";
-import { ArrowRight, OpenNewTab, Reload, ThreeDotSaving } from "@dextinity/admin-icons";
-import { Box, Chip, IconButton } from "@mui/material";
+import { Reload, ThreeDotSaving } from "@dextinity/admin-icons";
+import { Chip, IconButton } from "@mui/material";
import type { GridSlotsComponent, GridToolbarProps } from "@mui/x-data-grid";
import { useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
-import { useHistory } from "react-router";
import { useContentScope } from "../contentScope/Provider";
+import { getContentScopeLabel } from "../contentScope/utils/getContentScopeLabel";
import { DataGrid } from "../dataGrid/DataGrid";
import type { GQLDependency } from "../graphql.generated";
import { useDependenciesConfig } from "./dependenciesConfig";
+import { DependencyActions } from "./DependencyActions";
import { getDisplayNameString } from "./getDisplayNameString";
-import type { DependencyInterface } from "./types";
-type DependencyItem = Pick & {
+type DependencyItem = Pick & {
id: string;
targetGraphqlObjectType: string;
};
type Dependency = Pick<
GQLDependency,
- "targetGraphqlObjectType" | "targetId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible"
+ "targetGraphqlObjectType" | "targetId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible" | "scope"
>;
interface DependenciesListQuery {
@@ -94,8 +94,6 @@ export const DependenciesList = ({ query, variables }: DependenciesListProps) =>
const intl = useIntl();
const { entityDependencyMap } = useDependenciesConfig();
const contentScope = useContentScope();
- const apolloClient = useApolloClient();
- const history = useHistory();
const dataGridProps = {
...useDataGridRemote({
@@ -108,6 +106,9 @@ export const DependenciesList = ({ query, variables }: DependenciesListProps) =>
...usePersistentColumnState("DependenciesList"),
};
+ // Scopes are only worth showing when the entries can actually originate from different scopes.
+ const showScopeColumn = contentScope.values.length > 1;
+
const columns: GridColDef[] = useMemo(
() => [
{
@@ -148,59 +149,36 @@ export const DependenciesList = ({ query, variables }: DependenciesListProps) =>
visible: false,
sortBy: "visible",
},
+ ...(showScopeColumn
+ ? [
+ {
+ field: "scope",
+ headerName: intl.formatMessage({ id: "dextinity.dependencies.dataGrid.scope", defaultMessage: "Scope" }),
+ width: 160,
+ filterable: false,
+ sortable: false,
+ valueGetter: (params, row) => (row.scope ? getContentScopeLabel({ scope: row.scope, values: contentScope.values }) : null),
+ } satisfies GridColDef,
+ ]
+ : []),
{
field: "actions",
type: "actions",
headerName: "",
filterable: false,
sortable: false,
- renderCell: ({ row }) => {
- const dependencyObject = entityDependencyMap[row.targetGraphqlObjectType] as DependencyInterface | undefined;
-
- if (dependencyObject === undefined) {
- if (process.env.NODE_ENV === "development") {
- console.warn(
- `Cannot load URL because no implementation of DependencyInterface for ${row.targetGraphqlObjectType} was provided via the DependenciesConfig`,
- );
- }
- return ;
- }
-
- const loadUrl = async () => {
- const path = await dependencyObject.resolvePath({
- rootColumnName: row.rootColumnName,
- jsonPath: row.jsonPath,
- apolloClient,
- id: row.id,
- });
- return contentScope.match.url + path;
- };
-
- return (
-
- {
- const url = await loadUrl();
- window.open(url, "_blank");
- }}
- >
-
-
- {
- const url = await loadUrl();
-
- history.push(url);
- }}
- >
-
-
-
- );
- },
+ renderCell: ({ row }) => (
+
+ ),
},
],
- [intl, entityDependencyMap, apolloClient, contentScope, history],
+ [intl, entityDependencyMap, showScopeColumn, contentScope.values],
);
const { filter: gqlFilter } = muiGridFilterToGql(columns, dataGridProps.filterModel);
diff --git a/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx b/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx
new file mode 100644
index 00000000000..a58cfbbfc1a
--- /dev/null
+++ b/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx
@@ -0,0 +1,104 @@
+import { useApolloClient } from "@apollo/client";
+import { messages, Tooltip } from "@dextinity/admin";
+import { ArrowRight, OpenNewTab } from "@dextinity/admin-icons";
+import { Box, IconButton } from "@mui/material";
+import { FormattedMessage } from "react-intl";
+import { useHistory } from "react-router";
+
+import { type ContentScope, useContentScope } from "../contentScope/Provider";
+import { useDependenciesConfig } from "./dependenciesConfig";
+import { resolveDependencyScope } from "./resolveDependencyScope";
+import type { DependencyInterface } from "./types";
+
+interface DependencyActionsProps {
+ graphqlObjectType: string;
+ id: string;
+ rootColumnName?: string;
+ jsonPath?: string;
+ /**
+ * Scope of the linked entity. Entities can be used across scopes (e.g. a DAM file shared between sites), which is
+ * why the link must not be built with the currently active scope.
+ */
+ scope?: ContentScope;
+}
+
+export const DependencyActions = ({ graphqlObjectType, id, rootColumnName, jsonPath, scope }: DependencyActionsProps) => {
+ const { entityDependencyMap } = useDependenciesConfig();
+ const apolloClient = useApolloClient();
+ const history = useHistory();
+ const contentScope = useContentScope();
+
+ const dependencyObject = entityDependencyMap[graphqlObjectType] as DependencyInterface | undefined;
+
+ if (dependencyObject === undefined) {
+ if (process.env.NODE_ENV === "development") {
+ console.warn(
+ `Cannot load URL because no implementation of DependencyInterface for ${graphqlObjectType} was provided via the DependenciesConfig`,
+ );
+ }
+ return ;
+ }
+
+ const scopeToOpen = scope && resolveDependencyScope({ scope, activeScope: contentScope.scope, availableScopes: contentScope.values });
+
+ if (scope !== undefined && scopeToOpen === undefined) {
+ return (
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const loadUrl = async () => {
+ const path = await dependencyObject.resolvePath({
+ rootColumnName,
+ jsonPath,
+ apolloClient,
+ id,
+ });
+
+ const scopeUrl = scopeToOpen ? contentScope.createUrl(scopeToOpen) : contentScope.match.url;
+
+ return scopeUrl + path;
+ };
+
+ return (
+
+ }>
+ {
+ const url = await loadUrl();
+ window.open(url, "_blank");
+ }}
+ >
+
+
+
+ }>
+ {
+ const url = await loadUrl();
+
+ history.push(url);
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/packages/admin/cms-admin/src/dependencies/DependentsList.tsx b/packages/admin/cms-admin/src/dependencies/DependentsList.tsx
index 2563b81bfd5..9434f69bf2e 100644
--- a/packages/admin/cms-admin/src/dependencies/DependentsList.tsx
+++ b/packages/admin/cms-admin/src/dependencies/DependentsList.tsx
@@ -1,4 +1,4 @@
-import { type QueryResult, type TypedDocumentNode, useApolloClient, useQuery } from "@apollo/client";
+import { type QueryResult, type TypedDocumentNode, useQuery } from "@apollo/client";
import {
Alert,
DataGridToolbar,
@@ -15,28 +15,28 @@ import {
useDataGridRemote,
usePersistentColumnState,
} from "@dextinity/admin";
-import { ArrowRight, OpenNewTab, Reload, ThreeDotSaving } from "@dextinity/admin-icons";
-import { Box, Chip, IconButton } from "@mui/material";
+import { Reload, ThreeDotSaving } from "@dextinity/admin-icons";
+import { Chip, IconButton } from "@mui/material";
import type { GridSlotsComponent, GridToolbarProps } from "@mui/x-data-grid";
import { useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
-import { useHistory } from "react-router";
import { useContentScope } from "../contentScope/Provider";
+import { getContentScopeLabel } from "../contentScope/utils/getContentScopeLabel";
import { DataGrid } from "../dataGrid/DataGrid";
import type { GQLDependency } from "../graphql.generated";
import { useDependenciesConfig } from "./dependenciesConfig";
+import { DependencyActions } from "./DependencyActions";
import { getDisplayNameString } from "./getDisplayNameString";
-import type { DependencyInterface } from "./types";
-type DependencyItem = Pick & {
+type DependencyItem = Pick & {
id: string;
rootGraphqlObjectType: string;
};
type Dependent = Pick<
GQLDependency,
- "rootGraphqlObjectType" | "rootId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible"
+ "rootGraphqlObjectType" | "rootId" | "rootColumnName" | "jsonPath" | "name" | "secondaryInformation" | "visible" | "scope"
>;
interface DependentsListQuery {
@@ -94,8 +94,6 @@ export const DependentsList = ({ query, variables }: DependentsListProps) => {
const intl = useIntl();
const { entityDependencyMap } = useDependenciesConfig();
const contentScope = useContentScope();
- const apolloClient = useApolloClient();
- const history = useHistory();
const dataGridProps = {
...useDataGridRemote({
@@ -108,6 +106,9 @@ export const DependentsList = ({ query, variables }: DependentsListProps) => {
...usePersistentColumnState("DependentsList"),
};
+ // Scopes are only worth showing when the entries can actually originate from different scopes.
+ const showScopeColumn = contentScope.values.length > 1;
+
const columns: GridColDef[] = useMemo(
() => [
{
@@ -146,59 +147,36 @@ export const DependentsList = ({ query, variables }: DependentsListProps) => {
visible: false,
sortBy: "visible",
},
+ ...(showScopeColumn
+ ? [
+ {
+ field: "scope",
+ headerName: intl.formatMessage({ id: "dextinity.dependencies.dataGrid.scope", defaultMessage: "Scope" }),
+ width: 160,
+ filterable: false,
+ sortable: false,
+ valueGetter: (params, row) => (row.scope ? getContentScopeLabel({ scope: row.scope, values: contentScope.values }) : null),
+ } satisfies GridColDef,
+ ]
+ : []),
{
field: "actions",
type: "actions",
headerName: "",
filterable: false,
sortable: false,
- renderCell: ({ row }) => {
- const dependencyObject = entityDependencyMap[row.rootGraphqlObjectType] as DependencyInterface | undefined;
-
- if (dependencyObject === undefined) {
- if (process.env.NODE_ENV === "development") {
- console.warn(
- `Cannot load URL because no implementation of DependencyInterface for ${row.rootGraphqlObjectType} was provided via the DependenciesConfig`,
- );
- }
- return ;
- }
-
- const loadUrl = async () => {
- const path = await dependencyObject.resolvePath({
- rootColumnName: row.rootColumnName,
- jsonPath: row.jsonPath,
- apolloClient,
- id: row.id,
- });
- return contentScope.match.url + path;
- };
-
- return (
-
- {
- const url = await loadUrl();
- window.open(url, "_blank");
- }}
- >
-
-
- {
- const url = await loadUrl();
-
- history.push(url);
- }}
- >
-
-
-
- );
- },
+ renderCell: ({ row }) => (
+
+ ),
},
],
- [intl, entityDependencyMap, apolloClient, contentScope, history],
+ [intl, entityDependencyMap, showScopeColumn, contentScope.values],
);
const { filter: gqlFilter } = muiGridFilterToGql(columns, dataGridProps.filterModel);
diff --git a/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.test.ts b/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.test.ts
new file mode 100644
index 00000000000..0b3a32ec3f5
--- /dev/null
+++ b/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+
+import type { ContentScopeValues } from "../contentScope/Provider";
+import { resolveDependencyScope } from "./resolveDependencyScope";
+
+const availableScopes: ContentScopeValues = [{ scope: { domain: "main", language: "de" } }, { scope: { domain: "secondary", language: "en" } }];
+
+describe("resolveDependencyScope", () => {
+ it("should keep the active scope for an entry from that scope", () => {
+ expect(
+ resolveDependencyScope({
+ scope: { domain: "secondary", language: "en" },
+ activeScope: { domain: "secondary", language: "en" },
+ availableScopes,
+ }),
+ ).toEqual({ domain: "secondary", language: "en" });
+ });
+
+ it("should merge an incomplete scope into the active scope", () => {
+ expect(
+ resolveDependencyScope({
+ scope: { domain: "main" },
+ activeScope: { domain: "main", language: "de" },
+ availableScopes,
+ }),
+ ).toEqual({ domain: "main", language: "de" });
+ });
+
+ it("should use an available scope when the merged scope is not available", () => {
+ expect(
+ resolveDependencyScope({
+ scope: { domain: "main" },
+ activeScope: { domain: "secondary", language: "en" },
+ availableScopes,
+ }),
+ ).toEqual({ domain: "main", language: "de" });
+ });
+
+ it("should return undefined when no available scope contains the entry's scope", () => {
+ expect(
+ resolveDependencyScope({
+ scope: { domain: "third" },
+ activeScope: { domain: "main", language: "de" },
+ availableScopes,
+ }),
+ ).toBeUndefined();
+ });
+
+ it("should merge into the active scope when no scopes are available for comparison", () => {
+ expect(
+ resolveDependencyScope({
+ scope: { domain: "main" },
+ activeScope: { domain: "secondary", language: "en" },
+ availableScopes: [],
+ }),
+ ).toEqual({ domain: "main", language: "en" });
+ });
+});
diff --git a/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.ts b/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.ts
new file mode 100644
index 00000000000..416b5c75632
--- /dev/null
+++ b/packages/admin/cms-admin/src/dependencies/resolveDependencyScope.ts
@@ -0,0 +1,31 @@
+import type { ContentScope, ContentScopeValues } from "../contentScope/Provider";
+import { isScopePartOf } from "../contentScope/utils/isScopePartOf";
+
+/**
+ * Determines the scope an entry must be opened in, or undefined when the user has access to none.
+ *
+ * An entry's scope can be incomplete (e.g. a DAM file scoped by domain only), which is why it is merged into the active
+ * scope instead of replacing it. Should the user have no access to that combination, the first of their scopes
+ * containing the entry's scope is used.
+ */
+export function resolveDependencyScope({
+ scope,
+ activeScope,
+ availableScopes,
+}: {
+ scope: ContentScope;
+ activeScope: ContentScope;
+ availableScopes: ContentScopeValues;
+}): ContentScope | undefined {
+ const scopeInActiveScope = { ...activeScope, ...scope };
+
+ if (availableScopes.length === 0) {
+ return scopeInActiveScope;
+ }
+
+ if (availableScopes.some(({ scope: availableScope }) => isScopePartOf(scopeInActiveScope, availableScope))) {
+ return scopeInActiveScope;
+ }
+
+ return availableScopes.find(({ scope: availableScope }) => isScopePartOf(scope, availableScope))?.scope;
+}
diff --git a/packages/api/brevo-api/schema.gql b/packages/api/brevo-api/schema.gql
index 7cdc78c657c..70fc52dada6 100644
--- a/packages/api/brevo-api/schema.gql
+++ b/packages/api/brevo-api/schema.gql
@@ -50,6 +50,7 @@ type Dependency {
targetId: String!
name: String
secondaryInformation: String
+ scope: JSONObject
}
type DamMediaAlternative {
diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql
index 5ccdfba6b1d..b110bbf6f5f 100644
--- a/packages/api/cms-api/schema.gql
+++ b/packages/api/cms-api/schema.gql
@@ -91,6 +91,7 @@ type Dependency {
targetId: String!
name: String
secondaryInformation: String
+ scope: JSONObject
}
type PaginatedDependencies {
diff --git a/packages/api/cms-api/src/dependencies/dependencies.service.ts b/packages/api/cms-api/src/dependencies/dependencies.service.ts
index 3c5d830b6ec..0b9e2c513c7 100644
--- a/packages/api/cms-api/src/dependencies/dependencies.service.ts
+++ b/packages/api/cms-api/src/dependencies/dependencies.service.ts
@@ -131,8 +131,10 @@ export class DependenciesService {
blockIndex."targetId",
ei_root."name" "rootName",
ei_root."secondaryInformation" "rootSecondaryInformation",
+ ei_root."scopes"->0 "rootScope",
ei_target."name" "targetName",
- ei_target."secondaryInformation" "targetSecondaryInformation"
+ ei_target."secondaryInformation" "targetSecondaryInformation",
+ ei_target."scopes"->0 "targetScope"
FROM (
${indexSelects.join("\n UNION ALL \n")}
) blockIndex
@@ -443,6 +445,7 @@ export class DependenciesService {
Object.assign(result, entity);
result.name = context === "dependents" ? entity.rootName : entity.targetName;
result.secondaryInformation = context === "dependents" ? entity.rootSecondaryInformation : entity.targetSecondaryInformation;
+ result.scope = context === "dependents" ? entity.rootScope : entity.targetScope;
return result;
}
}
diff --git a/packages/api/cms-api/src/dependencies/dto/dependency.ts b/packages/api/cms-api/src/dependencies/dto/dependency.ts
index bae5c2262ce..c6f826f4484 100644
--- a/packages/api/cms-api/src/dependencies/dto/dependency.ts
+++ b/packages/api/cms-api/src/dependencies/dto/dependency.ts
@@ -1,5 +1,7 @@
import { Field, ObjectType } from "@nestjs/graphql";
+import { GraphQLJSONObject } from "graphql-scalars";
+import { ContentScope } from "../../user-permissions/interfaces/content-scope.interface";
import { BaseDependencyInterface } from "./base-dependency.interface";
@ObjectType()
@@ -44,4 +46,11 @@ export class Dependency implements BaseDependencyInterface {
@Field({ nullable: true })
secondaryInformation?: string;
+
+ /**
+ * Content scope of the dependent (root) resp. depended-on (target) entity. Undefined for entities without a scope
+ * and for entities whose scope cannot be resolved. Entities with multiple scopes report their first scope.
+ */
+ @Field(() => GraphQLJSONObject, { nullable: true })
+ scope?: ContentScope;
}
diff --git a/packages/api/cms-api/src/dependencies/entities/block-index-dependency.object.ts b/packages/api/cms-api/src/dependencies/entities/block-index-dependency.object.ts
index 25763c4c116..3719a920d51 100644
--- a/packages/api/cms-api/src/dependencies/entities/block-index-dependency.object.ts
+++ b/packages/api/cms-api/src/dependencies/entities/block-index-dependency.object.ts
@@ -1,5 +1,7 @@
import { Entity, PrimaryKey, Property } from "@mikro-orm/core";
+import { ContentScope } from "../../user-permissions/interfaces/content-scope.interface";
+
// Note: This file is intentionally not named *.entity.ts to exclude it from MikroORM's CLI migration glob pattern.
// The "block_index_dependencies" materialized view is created dynamically at startup by DependenciesService, not via migrations.
@@ -59,9 +61,15 @@ export class BlockIndexDependencyObject {
@Property({ type: "text", nullable: true })
rootSecondaryInformation?: string;
+ @Property({ type: "jsonb", nullable: true })
+ rootScope?: ContentScope;
+
@Property({ type: "text", nullable: true })
targetName?: string;
@Property({ type: "text", nullable: true })
targetSecondaryInformation?: string;
+
+ @Property({ type: "jsonb", nullable: true })
+ targetScope?: ContentScope;
}
diff --git a/packages/api/cms-api/src/entity-info/entity-info.service.ts b/packages/api/cms-api/src/entity-info/entity-info.service.ts
index be14c8c3335..fc5db9d33cd 100644
--- a/packages/api/cms-api/src/entity-info/entity-info.service.ts
+++ b/packages/api/cms-api/src/entity-info/entity-info.service.ts
@@ -1,12 +1,15 @@
-import { AnyEntity, EntityManager } from "@mikro-orm/postgresql";
+import { AnyEntity, EntityManager, EntityMetadata } from "@mikro-orm/postgresql";
import { Injectable, Logger } from "@nestjs/common";
import { DiscoverService } from "../dependencies/discover.service";
+import { PAGE_TREE_ENTITY } from "../page-tree/page-tree.constants";
import { REQUIRED_PERMISSION_METADATA_KEY, RequiredPermissionMetadata } from "../user-permissions/decorators/required-permission.decorator";
+import { SCOPED_ENTITY_METADATA_KEY, ScopedEntityMeta } from "../user-permissions/decorators/scoped-entity.decorator";
import { ENTITY_INFO_METADATA_KEY, EntityInfo } from "./entity-info.decorator";
import { EntityInfoObject } from "./entity-info.object";
import { isEntityInfoSql, requiredPermissionToSql } from "./entity-info.utils";
import { resolveFieldToSql } from "./resolve-field-to-sql";
+import { NO_SCOPES_SQL, resolveScopesToSql } from "./resolve-scopes-to-sql";
@Injectable()
export class EntityInfoService {
@@ -33,8 +36,18 @@ export class EntityInfoService {
| undefined;
const requiredPermissionSql = requiredPermissionToSql(permissionMetadata?.requiredPermission);
+ const { metadata } = targetEntity;
+ const scopesSql = this.resolveScopesSql(targetEntity);
+
+ // The raw SQL may select from a dedicated view that doesn't know about the entity's scope. Join the
+ // entity's own table (on the id the SQL is required to return) to resolve the scope from it.
+ const scopeJoin =
+ scopesSql === NO_SCOPES_SQL
+ ? ""
+ : ` LEFT JOIN "${metadata.tableName}" ON "${metadata.tableName}"."${metadata.primaryKeys[0]}"::text = sub."id"`;
+
indexSelects.push(
- `SELECT sub."name", sub."secondaryInformation", sub."visible", sub."id", sub."entityName", ${requiredPermissionSql} AS "requiredPermission" FROM (${sql}) sub`,
+ `SELECT sub."name", sub."secondaryInformation", sub."visible", sub."id", sub."entityName", ${requiredPermissionSql} AS "requiredPermission", ${scopesSql} AS "scopes" FROM (${sql}) sub${scopeJoin}`,
);
} else {
const { entityName, metadata } = targetEntity;
@@ -72,16 +85,27 @@ export class EntityInfoService {
${visibleSql} AS "visible",
"${metadata.tableName}"."${primary}"::text "id",
'${entityName}' "entityName",
- ${requiredPermissionSql} AS "requiredPermission"
+ ${requiredPermissionSql} AS "requiredPermission",
+ ${this.resolveScopesSql(targetEntity)} AS "scopes"
FROM "${metadata.tableName}"`;
indexSelects.push(select);
}
}
// add all PageTreeNode Documents (Page, Link etc) thru PageTreeNodeDocument (no @EntityInfo needed on Page/Link)
- indexSelects.push(`SELECT "PageTreeNodeEntityInfo"."name", "PageTreeNodeEntityInfo"."secondaryInformation", "PageTreeNodeEntityInfo"."visible", "PageTreeNodeDocument"."documentId"::text "id", "type" "entityName", ARRAY['pageTree']::text[] AS "requiredPermission"
+ // Documents are scoped by their page tree node, which is why the scope is resolved from it instead of from the
+ // document entity (whose @ScopedEntity is a service and therefore not convertible to SQL).
+ const pageTreeNode = targetEntities.find((targetEntity) => targetEntity.metadata.tableName === PAGE_TREE_ENTITY);
+ const pageTreeNodeScopesSql = pageTreeNode ? this.resolveScopesSql(pageTreeNode) : NO_SCOPES_SQL;
+ const pageTreeNodeScopeJoin =
+ pageTreeNodeScopesSql === NO_SCOPES_SQL
+ ? ""
+ : `LEFT JOIN "${PAGE_TREE_ENTITY}" ON "${PAGE_TREE_ENTITY}"."id" = "PageTreeNodeDocument"."pageTreeNodeId"`;
+
+ indexSelects.push(`SELECT "PageTreeNodeEntityInfo"."name", "PageTreeNodeEntityInfo"."secondaryInformation", "PageTreeNodeEntityInfo"."visible", "PageTreeNodeDocument"."documentId"::text "id", "type" "entityName", ARRAY['pageTree']::text[] AS "requiredPermission", ${pageTreeNodeScopesSql} AS "scopes"
FROM "PageTreeNodeDocument"
JOIN "PageTreeNodeEntityInfo" ON "PageTreeNodeEntityInfo"."id" = "PageTreeNodeDocument"."pageTreeNodeId"::text
+ ${pageTreeNodeScopeJoin}
`);
const viewSql = indexSelects.join("\n UNION ALL \n");
@@ -92,6 +116,14 @@ export class EntityInfoService {
console.timeEnd("creating EntityInfo view");
}
+ private resolveScopesSql(targetEntity: { entity: AnyEntity; metadata: EntityMetadata }): string {
+ const scopedEntity = Reflect.getMetadata(SCOPED_ENTITY_METADATA_KEY, targetEntity.entity) as ScopedEntityMeta | undefined;
+
+ // The EntityInfo view is created on every application start, so an entity whose scope cannot be resolved must
+ // not break the view creation. It contributes no scope instead.
+ return resolveScopesToSql({ metadata: targetEntity.metadata, scopedEntity, onUnsupported: "null" });
+ }
+
async dropEntityInfoView() {
await this.entityManager.getConnection().execute(`DROP VIEW IF EXISTS "EntityInfo"`);
}
diff --git a/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.spec.ts b/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.spec.ts
index ff669d0537f..6e7befae122 100644
--- a/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.spec.ts
+++ b/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.spec.ts
@@ -144,5 +144,12 @@ describe("resolveScopesToSql", () => {
/cannot be converted to SQL/,
);
});
+
+ it("returns NULL::jsonb instead of throwing when onUnsupported is null", () => {
+ expect(resolveScopesToSql({ metadata: metadataFor("NewsComment"), scopedEntity: () => ({}), onUnsupported: "null" })).toBe("NULL::jsonb");
+ expect(resolveScopesToSql({ metadata: metadataFor("NewsComment"), scopedEntity: NewsCommentScopeService, onUnsupported: "null" })).toBe(
+ "NULL::jsonb",
+ );
+ });
});
});
diff --git a/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.ts b/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.ts
index 555fbe31bb0..a4f97c0df2b 100644
--- a/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.ts
+++ b/packages/api/cms-api/src/entity-info/resolve-scopes-to-sql.ts
@@ -3,6 +3,12 @@ import type { EntityMetadata, EntityProperty } from "@mikro-orm/postgresql";
import { isEntityScopeMapping, type ScopedEntityMeta, type SingleEntityScopeMapping } from "../user-permissions/decorators/scoped-entity.decorator";
import { resolveFieldToSql } from "./resolve-field-to-sql";
+/**
+ * SQL expression used when an entity's scope cannot be determined (no scope at all, or a `@ScopedEntity` that cannot
+ * be converted to SQL).
+ */
+export const NO_SCOPES_SQL = "NULL::jsonb";
+
/**
* Resolves the scope(s) of an entity to a SQL expression returning a `jsonb` array of scopes (or `NULL::jsonb`).
*
@@ -10,9 +16,18 @@ import { resolveFieldToSql } from "./resolve-field-to-sql";
* 1. a `scope` property on the entity (simple case)
* 2. a SQL-convertible `@ScopedEntity` mapping (string field path, object mapping, or an array of these for multiple scopes)
*
- * A callback or service `@ScopedEntity` cannot be converted to SQL and therefore throws.
+ * A callback or service `@ScopedEntity` cannot be converted to SQL. Depending on `onUnsupported`, this either throws
+ * (default) or resolves to `NULL::jsonb`, which is used where a missing scope must not break the view creation.
*/
-export function resolveScopesToSql({ metadata, scopedEntity }: { metadata: EntityMetadata; scopedEntity: ScopedEntityMeta | undefined }): string {
+export function resolveScopesToSql({
+ metadata,
+ scopedEntity,
+ onUnsupported = "throw",
+}: {
+ metadata: EntityMetadata;
+ scopedEntity: ScopedEntityMeta | undefined;
+ onUnsupported?: "throw" | "null";
+}): string {
const scopeProp = metadata.props.find((prop) => prop.name === "scope");
if (scopeProp) {
return `jsonb_build_array(${scopePropertyToJsonbSql(scopeProp, metadata.tableName)})`;
@@ -20,6 +35,10 @@ export function resolveScopesToSql({ metadata, scopedEntity }: { metadata: Entit
if (scopedEntity) {
if (!isEntityScopeMapping(scopedEntity)) {
+ if (onUnsupported === "null") {
+ return NO_SCOPES_SQL;
+ }
+
throw new Error(
`Entity "${metadata.className}" uses a @ScopedEntity callback or service that cannot be converted to SQL, which the FullTextSearchModule requires. ` +
`Use the field-path string (e.g. @ScopedEntity("company.scope")) or the object mapping (e.g. @ScopedEntity({ companyId: "company.id" })) variant instead.`,
@@ -30,7 +49,7 @@ export function resolveScopesToSql({ metadata, scopedEntity }: { metadata: Entit
return `jsonb_build_array(${scopeSqls.join(", ")})`;
}
- return "NULL::jsonb";
+ return NO_SCOPES_SQL;
}
function resolveScopeMappingToSql(mapping: SingleEntityScopeMapping, metadata: EntityMetadata, tableName: string): string {