Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/gentle-clouds-invite.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions demo/admin/src/documents/pages/EditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const pageTreeNodeDependentsQuery = gql`
name
secondaryInformation
visible
scope
}
totalCount
}
Expand Down Expand Up @@ -88,6 +89,7 @@ const pageTreeNodeDependenciesQuery = gql`
name
secondaryInformation
visible
scope
}
totalCount
}
Expand Down
1 change: 1 addition & 0 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ type Dependency {
rootColumnName: String!
rootGraphqlObjectType: String!
rootId: String!
scope: JSONObject
secondaryInformation: String
targetGraphqlObjectType: String!
targetId: String!
Expand Down
19 changes: 18 additions & 1 deletion docs/docs/2-core-concepts/7-dependencies/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -320,6 +326,7 @@ Each component requires two props:
name
secondaryInformation
visible
scope
}
totalCount
}
Expand All @@ -333,3 +340,13 @@ Each component requires two props:
```

</details>

#### 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = <FormattedMessage {...messages.globalContentScope} />;
} 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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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(" / ");
}
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions packages/admin/cms-admin/src/contentScope/utils/isScopePartOf.ts
Original file line number Diff line number Diff line change
@@ -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));
}
1 change: 1 addition & 0 deletions packages/admin/cms-admin/src/dam/FileForm/EditFile.gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const damFileDependentsQuery = gql`
name
secondaryInformation
visible
scope
}
totalCount
}
Expand Down
86 changes: 32 additions & 54 deletions packages/admin/cms-admin/src/dependencies/DependenciesList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<GQLDependency, "name" | "secondaryInformation" | "visible" | "rootColumnName" | "jsonPath"> & {
type DependencyItem = Pick<GQLDependency, "name" | "secondaryInformation" | "visible" | "rootColumnName" | "jsonPath" | "scope"> & {
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 {
Expand Down Expand Up @@ -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({
Expand All @@ -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<DependencyItem>[] = useMemo(
() => [
{
Expand Down Expand Up @@ -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<DependencyItem>,
]
: []),
{
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 <FormattedMessage id="dextinity.dependencies.dataGrid.cannotLoadUrl" defaultMessage="Cannot determine URL" />;
}

const loadUrl = async () => {
const path = await dependencyObject.resolvePath({
rootColumnName: row.rootColumnName,
jsonPath: row.jsonPath,
apolloClient,
id: row.id,
});
return contentScope.match.url + path;
};

return (
<Box display="flex">
<IconButton
onClick={async () => {
const url = await loadUrl();
window.open(url, "_blank");
}}
>
<OpenNewTab />
</IconButton>
<IconButton
onClick={async () => {
const url = await loadUrl();

history.push(url);
}}
>
<ArrowRight />
</IconButton>
</Box>
);
},
renderCell: ({ row }) => (
<DependencyActions
graphqlObjectType={row.targetGraphqlObjectType}
id={row.id}
rootColumnName={row.rootColumnName}
jsonPath={row.jsonPath}
scope={row.scope ?? undefined}
/>
),
},
],
[intl, entityDependencyMap, apolloClient, contentScope, history],
[intl, entityDependencyMap, showScopeColumn, contentScope.values],
);

const { filter: gqlFilter } = muiGridFilterToGql(columns, dataGridProps.filterModel);
Expand Down
Loading
Loading