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
40 changes: 40 additions & 0 deletions .changeset/narrow-content-scope-value-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@dextinity/cms-api": major
"@dextinity/brevo-api": major
"@dextinity/cms-admin": major
"@dextinity/site-nextjs": major
---

Restrict content scope values to `string | number | null | undefined`

The content scope interfaces accepted values of any type (`Record<string, any>`, `[key: string]: unknown`), which hid mistakes such as passing a scope class instead of a scope instance, or wrapping a scope in another object.
Scope dimensions are now typed as `string | number | null | undefined`:

- `ScopeInterface` (page tree), `RedirectScopeInterface` and `DamScopeInterface` in `@dextinity/cms-api`
- `EmailCampaignScopeInterface` in `@dextinity/brevo-api`
- `ContentScope` in `@dextinity/cms-admin`
- `scope` in the preview params returned by `previewParams()`, `legacyPagesRouterPreviewParams()` and `setSitePreviewParams()` in `@dextinity/site-nextjs`

**Migration**

TypeScript only gives classes an index signature when it is declared explicitly, so scope classes in your application need one.
It can be narrower than the interface, so a scope with only string dimensions declares `[key: string]: string`:

```ts
@Embeddable()
@ObjectType("PageTreeNodeScope")
@InputType("PageTreeNodeScopeInput")
export class PageTreeNodeScope {
[key: string]: string;

@Property({ columnType: "text" })
@Field()
@IsString()
domain: string;

@Property({ columnType: "text" })
@Field()
@IsString()
language: string;
}
```
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { IsString, MaxLength } from "class-validator";

export class EmailContactSubscribeScope {
[key: string]: string;

@IsString()
@MaxLength(64)
domain: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { IsString } from "class-validator";
@ObjectType()
@InputType("EmailCampaignContentScopeInput")
export class EmailCampaignContentScope {
[key: string]: string;

@Property({ columnType: "text" })
@Field()
@IsString()
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/dam/dto/dam-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { IsString } from "class-validator";
@ObjectType()
@InputType("DamScopeInput")
export class DamScope {
[key: string]: string;

@Property({ columnType: "text" })
@Field()
@IsString()
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/news/entities/news.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ registerEnumType(NewsCategory, {
@ObjectType("")
@InputType("NewsContentScopeInput")
export class NewsContentScope {
[key: string]: string;

@Property({ columnType: "text" })
@Field()
@IsString()
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/page-tree/dto/page-tree-node-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { IsString } from "class-validator";
@InputType("PageTreeNodeScopeInput") // name must not be changed in the app
// @TODO: disguise @ObjectType("PageTreeContentScope") and @InputType("PageTreeContentScopeInput") decorators under a custom decorator: f.i. @PageTreeNodeScope
export class PageTreeNodeScope {
[key: string]: string;

@Property({ columnType: "text" })
@Field()
@IsString()
Expand Down
2 changes: 2 additions & 0 deletions demo/api/src/redirects/dto/redirect-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { IsString } from "class-validator";
@InputType("RedirectScopeInput") // name must not be changed in the app
// @TODO: disguise @ObjectType("RedirectScope") and @InputType("RedirectScopeInput") decorators under a custom decorator: f.i. @RedirectScope
export class RedirectScope {
[key: string]: string;

@Index() // this does nothing, migration has to be created manually as the entity is in library
@Property({ columnType: "text" })
@Field()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useContentScope } from "@dextinity/cms-admin";
import { type ContentScope, useContentScope } from "@dextinity/cms-admin";
import type { JSX } from "react";

import { useBrevoConfig } from "../common/BrevoConfigProvider";
Expand All @@ -8,13 +8,10 @@ export function BrevoConfigPage(): JSX.Element {
const { scopeParts } = useBrevoConfig();
const { scope: completeScope } = useContentScope();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

return <BrevoConfigForm scope={scope} />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type GridColDef, Stack, StackPage, StackSwitch, StackToolbar } from "@dextinity/admin";
import { ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import { type ContentScope, ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import type { DocumentNode } from "graphql";
import type { JSX, ReactNode } from "react";
import { useIntl } from "react-intl";
Expand Down Expand Up @@ -27,13 +27,10 @@ function createBrevoContactsPage({
const { scopeParts } = useBrevoConfig();
const { scope: completeScope } = useContentScope();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

return (
<ConfigVerification scope={scope}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type GridColDef, Stack, StackPage, StackSwitch, StackToolbar } from "@dextinity/admin";
import { ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import { type ContentScope, ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import type { DocumentNode } from "graphql";
import type { JSX, ReactNode } from "react";
import { useIntl } from "react-intl";
Expand Down Expand Up @@ -31,13 +31,10 @@ function createBrevoTestContactsPage({
const scopeParts = passedScopeParts ?? brevoConfig.scopeParts;
const { scope: completeScope } = useContentScope();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

return (
<ConfigVerification scope={scope}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Stack, StackPage, StackSwitch, StackToolbar } from "@dextinity/admin";
import { type BlockInterface, ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import { type BlockInterface, type ContentScope, ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import type { JSX } from "react";
import { useIntl } from "react-intl";

Expand All @@ -20,13 +20,10 @@ export function createEmailCampaignsPage({ EmailCampaignContentBlock }: CreateEm
const { scope: completeScope } = useContentScope();
const intl = useIntl();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

return (
<ConfigVerification scope={scope}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { gql, useApolloClient, useQuery } from "@apollo/client";
import { Field, FinalForm, FinalFormSelect, SaveButton, Tooltip } from "@dextinity/admin";
import { Info, Newsletter } from "@dextinity/admin-icons";
import { BlockAdminComponentPaper, BlockAdminComponentSectionGroup, useContentScope } from "@dextinity/cms-admin";
import { BlockAdminComponentPaper, BlockAdminComponentSectionGroup, type ContentScope, useContentScope } from "@dextinity/cms-admin";
import { Card } from "@mui/material";
import { FormattedMessage } from "react-intl";

Expand Down Expand Up @@ -50,13 +50,10 @@ export const TestEmailCampaignForm = ({ id, isSendable = false, isCampaignCreate
const { scopeParts } = useBrevoConfig();
const { scope: completeScope } = useContentScope();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

// Contact creation is limited to 100 at a time. Therefore, 100 contacts are queried without using pagination.
const { data, loading, error } = useQuery(brevoTestContactsSelectQuery, {
Expand Down
13 changes: 5 additions & 8 deletions packages/admin/brevo-admin/src/targetGroups/TargetGroupsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Stack, StackPage, StackSwitch, Toolbar } from "@dextinity/admin";
import { ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import { type ContentScope, ContentScopeIndicator, useContentScope } from "@dextinity/cms-admin";
import type { DocumentNode } from "graphql";
import type { JSX, ReactNode } from "react";
import { useIntl } from "react-intl";
Expand All @@ -26,13 +26,10 @@ export function createTargetGroupsPage({ additionalFormFields, nodeFragment, inp
const { scope: completeScope } = useContentScope();
const intl = useIntl();

const scope = scopeParts.reduce(
(acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
},
{} as { [key: string]: unknown },
);
const scope = scopeParts.reduce((acc, scopePart) => {
acc[scopePart] = completeScope[scopePart];
return acc;
}, {} as ContentScope);

return (
<ConfigVerification scope={scope}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const ContentScopeIndicator = ({ global = false, scope: passedScope, chil
const label = values.find((value) => {
return value.scope[scopePart] === scope[scopePart];
})?.label;
return (label && label[scopePart]) ?? (scope[scopePart] ? capitalizeString(scope[scopePart]) : undefined);
return (label && label[scopePart]) ?? (scope[scopePart] ? capitalizeString(String(scope[scopePart])) : undefined);
};

let content: ReactNode;
Expand Down
14 changes: 9 additions & 5 deletions packages/admin/cms-admin/src/contentScope/ContentScopeSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,11 @@ export function ContentScopeSelect({
if (searchable) {
filteredOptions = options.filter((option) => {
return (
Object.values(option.scope).some((value) => value.toLowerCase().includes(searchValue.toLowerCase())) ||
Object.values(option.label || []).some((value) => value?.toLowerCase().includes(searchValue.toLowerCase()))
Object.values(option.scope).some((value) =>
String(value ?? "")
.toLowerCase()
.includes(searchValue.toLowerCase()),
) || Object.values(option.label || []).some((value) => value?.toLowerCase().includes(searchValue.toLowerCase()))
);
});
}
Expand All @@ -71,12 +74,13 @@ export function ContentScopeSelect({
if (groupBy) {
if (hasMultipleDimensions) {
for (const option of filteredOptions) {
const groupForOption = groups.find((group) => group.value === option.scope[groupBy]);
const groupValue = String(option.scope[groupBy] ?? "");
const groupForOption = groups.find((group) => group.value === groupValue);

if (groupForOption) {
groupForOption.options.push(option);
} else {
groups.push({ value: option.scope[groupBy], label: option.label ? option.label[groupBy] : undefined, options: [option] });
groups.push({ value: groupValue, label: option.label ? option.label[groupBy] : undefined, options: [option] });
}
}
} else {
Expand Down Expand Up @@ -125,7 +129,7 @@ export function ContentScopeSelect({
if (!renderSelectedOption) {
renderSelectedOption = (option) => {
return Object.keys(option.scope)
.map((key) => humanReadableLabel({ label: option.label ? option.label[key] : undefined, value: option.scope[key] }))
.map((key) => humanReadableLabel({ label: option.label ? option.label[key] : undefined, value: String(option.scope[key] ?? "") }))
.join(" / ");
};
}
Expand Down
22 changes: 9 additions & 13 deletions packages/admin/cms-admin/src/contentScope/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { NoContentScopeFallback } from "./noContentScopeFallback/NoContentScopeF
import { defaultCreatePath } from "./utils/defaultCreatePath";

export interface ContentScope {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
[key: string]: string | number | null | undefined;
}

type ContentScopeLocation = {
Expand All @@ -36,10 +35,7 @@ const defaultContentScopeContext: ContentScopeContext = {
location: defaultContentScopeLocation,
};

type NonNull<T> = T extends null ? never : T;
type NonNullRecord<T> = {
[P in keyof T]: NonNull<T[P]>;
};
type ContentScopeRouterParams = Record<string, string>;

type SetContentScopeAction = (state: ContentScope) => ContentScope;

Expand All @@ -64,7 +60,7 @@ const Context = createContext<ContentScopeContext>(defaultContentScopeContext);

const NullValueAsString = "-"; // used to represent null-values in the url

function parseScopeFromRouterMatchParams(params: NonNullRecord<ContentScope>): ContentScope {
function parseScopeFromRouterMatchParams(params: ContentScopeRouterParams): ContentScope {
return Object.entries(params).reduce((a, [key, value]) => {
return {
...a,
Expand All @@ -73,13 +69,13 @@ function parseScopeFromRouterMatchParams(params: NonNullRecord<ContentScope>): C
}, {} as ContentScope);
}

function formatScopeToRouterMatchParams(scope: Partial<ContentScope>): NonNullRecord<ContentScope> {
function formatScopeToRouterMatchParams(scope: ContentScope): ContentScopeRouterParams {
return Object.entries(scope).reduce((a, [key, value]) => {
return {
...a,
[key]: !value || value === null ? NullValueAsString : value,
[key]: value === null || value === undefined || value === "" ? NullValueAsString : String(value),
};
}, {} as NonNullRecord<ContentScope>);
}, {} as ContentScopeRouterParams);
}

function defaultCreateUrl(scope: ContentScope) {
Expand All @@ -93,7 +89,7 @@ function defaultCreateUrl(scope: ContentScope) {
export function useContentScope(): UseContentScopeApi {
const context = useContext(Context);
const history = useHistory();
const matchContextScope = useRouteMatch<NonNullRecord<ContentScope>>(context?.path || "");
const matchContextScope = useRouteMatch<ContentScopeRouterParams>(context?.path || "");
const matchDefault = useRouteMatch();
const match = matchContextScope || matchDefault;

Expand Down Expand Up @@ -124,7 +120,7 @@ export function useContentScope(): UseContentScopeApi {
export interface ContentScopeProviderProps {
defaultValue?: ContentScope;
values?: ContentScopeValues;
children: (p: { match: match<NonNullRecord<ContentScope>> }) => ReactNode;
children: (p: { match: match<ContentScopeRouterParams> }) => ReactNode;
location?: ContentScopeLocation;

/**
Expand All @@ -151,7 +147,7 @@ export function ContentScopeProvider({
}

const path = location.createPath(values);
const match = useRouteMatch<NonNullRecord<ContentScope>>(path);
const match = useRouteMatch<ContentScopeRouterParams>(path);
const [redirectPathAfterChange, setRedirectPathAfterChange] = useState<undefined | string>("");

if (values.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,9 @@ export const GroupingWithOptionalScopeParts = {
renderOption={(option, query, isSelected) => {
let text: string;
if (option.scope.company === undefined) {
text = option.label?.country ?? option.scope.country;
text = option.label?.country ?? String(option.scope.country);
} else {
text = option.label?.company ?? option.scope.company;
text = option.label?.company ?? String(option.scope.company);
}

const matches = findTextMatches(text, query);
Expand Down
4 changes: 3 additions & 1 deletion packages/admin/cms-admin/src/dam/config/DamScopeContext.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createContext } from "react";

export const DamScopeContext = createContext<Record<string, unknown>>({});
import type { ContentScope } from "../../contentScope/Provider";

export const DamScopeContext = createContext<ContentScope>({});
Loading
Loading