From 5c7a89d1552a6fc13a532b03c5207a1084633aaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 04:30:04 +0000 Subject: [PATCH] cms-admin: Fix ContentScopeControls groupBy fallback for scopes with different shapes The groupBy fallback derived a default dimension from the currently selected scope's keys, but that shape isn't representative of all available scopes. When scopes have different shapes (e.g. {domain} vs {company}), this could apply a groupBy that other scopes don't have, breaking the scope switcher. The fallback now only picks a dimension when every scope shares the exact same shape. Also require every option to have multiple dimensions before grouping is applied in ContentScopeSelect, since mixed-shape options aren't safe to group by a single dimension. --- ...ontent-scope-controls-group-by-fallback.md | 7 ++ .../src/contentScope/ContentScopeSelect.tsx | 4 +- .../src/contentScope/Controls.test.tsx | 64 +++++++++++++++++++ .../cms-admin/src/contentScope/Controls.tsx | 21 +++++- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-content-scope-controls-group-by-fallback.md create mode 100644 packages/admin/cms-admin/src/contentScope/Controls.test.tsx diff --git a/.changeset/fix-content-scope-controls-group-by-fallback.md b/.changeset/fix-content-scope-controls-group-by-fallback.md new file mode 100644 index 00000000000..bea325c96ca --- /dev/null +++ b/.changeset/fix-content-scope-controls-group-by-fallback.md @@ -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. diff --git a/packages/admin/cms-admin/src/contentScope/ContentScopeSelect.tsx b/packages/admin/cms-admin/src/contentScope/ContentScopeSelect.tsx index 1f85bd0dac7..b29ae3f2b82 100644 --- a/packages/admin/cms-admin/src/contentScope/ContentScopeSelect.tsx +++ b/packages/admin/cms-admin/src/contentScope/ContentScopeSelect.tsx @@ -53,7 +53,9 @@ export function ContentScopeSelect({ const [searchValue, setSearchValue] = useState(""); 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; diff --git a/packages/admin/cms-admin/src/contentScope/Controls.test.tsx b/packages/admin/cms-admin/src/contentScope/Controls.test.tsx new file mode 100644 index 00000000000..11e3d1fb547 --- /dev/null +++ b/packages/admin/cms-admin/src/contentScope/Controls.test.tsx @@ -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(); + 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(); + + 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(); + + 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"); + }); +}); diff --git a/packages/admin/cms-admin/src/contentScope/Controls.tsx b/packages/admin/cms-admin/src/contentScope/Controls.tsx index 4a4b0f69a2a..5e7ce91e146 100644 --- a/packages/admin/cms-admin/src/contentScope/Controls.tsx +++ b/packages/admin/cms-admin/src/contentScope/Controls.tsx @@ -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; @@ -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; +}