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 {