From 9d29be5483524097e82ded5b8c60f6cce095eb88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 11:24:25 +0000 Subject: [PATCH 1/3] Link dependents/dependencies to the scope they belong to Entities can be used across scopes, for instance a DAM that is shared between multiple sites. The dependents and dependencies lists built their links with the currently active scope, so a link to an entry from another scope led to a wrong or non-existent page. The lists also didn't show which scope an entry belongs to. Resolve the scope of each entity into the EntityInfo view (from its scope property or its @ScopedEntity decorator, and from the page tree node for documents) and expose it as Dependency.scope. Both lists use it to build the link and to show a scope column when more than one scope exists. An entry from a scope the user has no access to is no longer linked. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPHi52TAjb3P9aM5DX8ReX --- .changeset/gentle-clouds-invite.md | 34 ++++++ demo/admin/src/documents/pages/EditPage.tsx | 2 + demo/api/schema.gql | 1 + .../2-core-concepts/7-dependencies/index.md | 19 +++- .../contentScope/ContentScopeIndicator.tsx | 16 +-- .../utils/getContentScopeLabel.test.ts | 27 +++++ .../utils/getContentScopeLabel.ts | 21 ++++ .../contentScope/utils/isScopePartOf.test.ts | 29 +++++ .../src/contentScope/utils/isScopePartOf.ts | 13 +++ .../src/dam/FileForm/EditFile.gql.ts | 1 + .../src/dependencies/DependenciesList.tsx | 86 ++++++-------- .../src/dependencies/DependencyActions.tsx | 106 ++++++++++++++++++ .../src/dependencies/DependentsList.tsx | 86 ++++++-------- packages/api/cms-api/schema.gql | 1 + .../src/dependencies/dependencies.service.ts | 5 +- .../src/dependencies/dto/dependency.ts | 9 ++ .../entities/block-index-dependency.object.ts | 8 ++ .../src/entity-info/entity-info.service.ts | 40 ++++++- .../entity-info/resolve-scopes-to-sql.spec.ts | 7 ++ .../src/entity-info/resolve-scopes-to-sql.ts | 25 ++++- 20 files changed, 405 insertions(+), 131 deletions(-) create mode 100644 .changeset/gentle-clouds-invite.md create mode 100644 packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.test.ts create mode 100644 packages/admin/cms-admin/src/contentScope/utils/getContentScopeLabel.ts create mode 100644 packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.test.ts create mode 100644 packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.ts create mode 100644 packages/admin/cms-admin/src/dependencies/DependencyActions.tsx 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..da3b3778701 --- /dev/null +++ b/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx @@ -0,0 +1,106 @@ +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 { isScopePartOf } from "../contentScope/utils/isScopePartOf"; +import { useDependenciesConfig } from "./dependenciesConfig"; +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 isScopeAllowed = scope === undefined || contentScope.values.some(({ scope: allowedScope }) => isScopePartOf(scope, allowedScope)); + + if (!isScopeAllowed) { + return ( + + } + > + + + + + + + + + + ); + } + + const loadUrl = async () => { + const path = await dependencyObject.resolvePath({ + rootColumnName, + jsonPath, + apolloClient, + id, + }); + + // A scope can be incomplete (e.g. a DAM file scoped by domain only), therefore it is merged into the active + // scope instead of replacing it. + const scopeUrl = scope ? contentScope.createUrl({ ...contentScope.scope, ...scope }) : 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/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 { From 32b684dfdba994c2b30aa21234fd2d858f849294 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 12:50:28 +0000 Subject: [PATCH 2/3] brevo-api: Regenerate schema for the new Dependency.scope field The Dependency type comes from cms-api, so brevo-api's generated schema carries the field as well. The Lint pipeline regenerates every schema and fails when a committed one differs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPHi52TAjb3P9aM5DX8ReX --- packages/api/brevo-api/schema.gql | 1 + 1 file changed, 1 insertion(+) 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 { From 1a950fdb20d2c655af4cd9b9727af2514b975825 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:01:22 +0000 Subject: [PATCH 3/3] cms-admin: Open a dependency in a scope the user can access The check whether an entry may be opened ran against the entry's own scope, while the link was built from that scope merged into the active one. For an incomplete scope those differ: with access to main/de and secondary/en, an entry scoped to the main domain passed the check while the link led to main/en. Resolve the scope to open first and validate that one, falling back to the first available scope containing the entry's scope. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UPHi52TAjb3P9aM5DX8ReX --- .../src/dependencies/DependencyActions.tsx | 10 ++-- .../resolveDependencyScope.test.ts | 58 +++++++++++++++++++ .../dependencies/resolveDependencyScope.ts | 31 ++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 packages/admin/cms-admin/src/dependencies/resolveDependencyScope.test.ts create mode 100644 packages/admin/cms-admin/src/dependencies/resolveDependencyScope.ts diff --git a/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx b/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx index da3b3778701..a58cfbbfc1a 100644 --- a/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx +++ b/packages/admin/cms-admin/src/dependencies/DependencyActions.tsx @@ -6,8 +6,8 @@ import { FormattedMessage } from "react-intl"; import { useHistory } from "react-router"; import { type ContentScope, useContentScope } from "../contentScope/Provider"; -import { isScopePartOf } from "../contentScope/utils/isScopePartOf"; import { useDependenciesConfig } from "./dependenciesConfig"; +import { resolveDependencyScope } from "./resolveDependencyScope"; import type { DependencyInterface } from "./types"; interface DependencyActionsProps { @@ -39,9 +39,9 @@ export const DependencyActions = ({ graphqlObjectType, id, rootColumnName, jsonP return ; } - const isScopeAllowed = scope === undefined || contentScope.values.some(({ scope: allowedScope }) => isScopePartOf(scope, allowedScope)); + const scopeToOpen = scope && resolveDependencyScope({ scope, activeScope: contentScope.scope, availableScopes: contentScope.values }); - if (!isScopeAllowed) { + if (scope !== undefined && scopeToOpen === undefined) { return ( { + 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; +}