Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/fix-content-scope-controls-group-by-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@dextinity/cms-admin": patch
---

Fix `ContentScopeControls`' `groupBy` fallback for scopes with different shapes

The fallback previously derived a `groupBy` dimension from the currently selected scope. If scopes have different shapes (e.g. `{ domain: "main" }` and `{ company: "123" }`), this could pick a dimension that other scopes don't have, breaking grouping when switching scopes. The fallback now only applies a `groupBy` dimension when every scope shares the exact same set of dimensions.
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export function ContentScopeSelect({
const [searchValue, setSearchValue] = useState<string>("");
const theme = useTheme();

const hasMultipleDimensions = options.some((option) => Object.keys(option.scope).length > 1);
// Grouping indexes into each option's scope by the groupBy dimension, so it's only safe when every
// option actually has more than one dimension - a mix of shapes would leave some options ungroupable.
const hasMultipleDimensions = options.length > 0 && options.every((option) => Object.keys(option.scope).length > 1);

let filteredOptions = options;

Expand Down
64 changes: 64 additions & 0 deletions packages/admin/cms-admin/src/contentScope/Controls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { cleanup, fireEvent, render, screen, within } from "test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";

import { ContentScopeControls } from "./Controls";
import type { ContentScopeValues } from "./Provider";

let mockValues: ContentScopeValues = [];

vi.mock("./Provider", async (importOriginal) => {
const actual = await importOriginal<typeof import("./Provider")>();
return {
...actual,
useContentScope: () => ({
scope: mockValues[0]?.scope ?? {},
setScope: vi.fn(),
values: mockValues,
}),
};
});

describe("ContentScopeControls", () => {
afterEach(() => {
cleanup();
});

it("does not group and does not crash when scopes have different shapes", () => {
mockValues = [
{ scope: { domain: "main" }, label: { domain: "Main" } },
{ scope: { company: "acme" }, label: { company: "Acme" } },
];

render(<ContentScopeControls />);

const [button] = screen.getAllByRole("button");
expect(() => fireEvent.click(button)).not.toThrow();

const list = within(screen.getByRole("list"));
list.getByText("Acme");
list.getByText("Main");
});

it("groups by the shared dimension when all scopes have the same shape", () => {
mockValues = [
{ scope: { domain: "main", language: "en" }, label: { domain: "Main", language: "EN" } },
{ scope: { domain: "main", language: "de" }, label: { domain: "Main", language: "DE" } },
{ scope: { domain: "secondary", language: "fr" }, label: { domain: "Secondary", language: "FR" } },
];

render(<ContentScopeControls />);

const [button] = screen.getAllByRole("button");
fireEvent.click(button);

const list = within(screen.getByRole("list"));

// Grouped by "domain" (the shared dimension), so its values appear as group headers ...
list.getByText("Main");
list.getByText("Secondary");
// ... and the options within a group are rendered by their other dimension only.
list.getByText("EN");
list.getByText("DE");
list.getByText("FR");
});
});
21 changes: 19 additions & 2 deletions packages/admin/cms-admin/src/contentScope/Controls.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JSX, ReactNode } from "react";

import { ContentScopeSelect } from "./ContentScopeSelect";
import { type ContentScope, useContentScope } from "./Provider";
import { type ContentScope, type ContentScopeValues, useContentScope } from "./Provider";

interface ContentScopeControlsProps {
searchable?: boolean;
Expand All @@ -20,7 +20,24 @@ export function ContentScopeControls({ searchable = true, icon, groupBy }: Conte
options={values}
searchable={searchable}
icon={icon}
groupBy={groupBy ?? Object.keys(scope)[0]}
groupBy={groupBy ?? getSharedDimension(values)}
/>
);
}

// The current scope's own shape isn't representative of all available scopes, so a fallback dimension
// is only safe to pick when every scope shares the exact same set of dimensions.
function getSharedDimension(values: ContentScopeValues): keyof ContentScope | undefined {
const [first, ...rest] = values;
if (!first) {
return undefined;
}

const dimensions = Object.keys(first.scope);
const allScopesShareDimensions = rest.every((value) => {
const otherDimensions = Object.keys(value.scope);
return otherDimensions.length === dimensions.length && dimensions.every((dimension) => otherDimensions.includes(dimension));
});

return allScopesShareDimensions ? dimensions[0] : undefined;
}
Loading