diff --git a/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.spec.ts b/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.spec.ts new file mode 100644 index 0000000000..57d4dd3bd8 --- /dev/null +++ b/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.spec.ts @@ -0,0 +1,98 @@ +/** + * This source file is available under the terms of the + * Pimcore Open Core License (POCL) + * Full copyright and license information is available in + * LICENSE.md which is distributed with this source code. + * + * @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com) + * @license Pimcore Open Core License (POCL) + */ + +import { renderHook } from '@testing-library/react' +import { useClassDefinitionGetTreeQuery, type ClassDefinitionTreeNodeItem, type ClassDefinitionTreeNodeFolder } from '@sdk/api/class-definition' +import { useClassDefinitionOptions } from './use-class-definition-options' + +jest.mock('@sdk/api/class-definition', () => ({ + useClassDefinitionGetTreeQuery: jest.fn() +})) + +const mockedUseClassDefinitionGetTreeQuery = useClassDefinitionGetTreeQuery as jest.Mock + +const createItem = (name: string): ClassDefinitionTreeNodeItem => ({ + id: name.toLowerCase(), + name, + title: name, + icon: { type: 'name', value: 'data-object' }, + group: null, + enableGridLocking: false, + hasBrickField: false +}) + +const createFolder = (name: string, children: ClassDefinitionTreeNodeItem[]): ClassDefinitionTreeNodeFolder => ({ + id: `folder_${name}`, + name, + title: name, + icon: { type: 'name', value: 'folder' }, + group: null, + children +}) + +const mockTreeData = (items: Array | undefined): void => { + mockedUseClassDefinitionGetTreeQuery.mockReturnValue({ + data: items === undefined ? undefined : { items, totalItems: items.length }, + refetch: jest.fn(), + isFetching: false + }) +} + +describe('useClassDefinitionOptions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('excludes folder nodes so that group names never appear as class options', () => { + // Groups derived from class name prefixes create folder nodes named after + // existing classes (e.g. folder "Material" containing the class "Material"). + mockTreeData([ + createFolder('Material', [createItem('Material'), createItem('MaterialGroup')]), + createFolder('Product', [createItem('Product'), createItem('ProductForm')]), + createItem('Series') + ]) + + const { result } = renderHook(() => useClassDefinitionOptions()) + const values = result.current.options.map((option) => option.value) + + expect(values).toEqual(['Material', 'MaterialGroup', 'Product', 'ProductForm', 'Series']) + }) + + it('does not produce duplicate option values when a folder shares its name with a class', () => { + mockTreeData([ + createFolder('Material', [createItem('Material'), createItem('MaterialGroup')]) + ]) + + const { result } = renderHook(() => useClassDefinitionOptions()) + const values = result.current.options.map((option) => option.value) + + expect(values).toHaveLength(new Set(values).size) + expect(values.filter((value) => value === 'Material')).toHaveLength(1) + }) + + it('prepends the special folder option when includeFolder is true', () => { + mockTreeData([createItem('Series')]) + + const { result } = renderHook(() => useClassDefinitionOptions(true)) + + expect(result.current.options).toEqual([ + { label: 'folder', value: 'folder' }, + { label: 'Series', value: 'Series' } + ]) + }) + + it('returns an empty option list while the tree is not loaded yet', () => { + mockTreeData(undefined) + + const { result } = renderHook(() => useClassDefinitionOptions()) + + expect(result.current.options).toEqual([]) + }) +}) diff --git a/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.ts b/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.ts index f4c73ad9fe..33803cf67f 100644 --- a/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.ts +++ b/assets/js/src/core/modules/field-definitions/dynamic-types/hooks/use-class-definition-options.ts @@ -10,7 +10,7 @@ import { useMemo } from 'react' import { useClassDefinitionGetTreeQuery, type ClassDefinitionTreeNodeItem, type ClassDefinitionTreeNodeFolder } from '@sdk/api/class-definition' -import { flatMap, isArray, isEmpty, isUndefined } from 'lodash' +import { flatMap, isArray, isUndefined } from 'lodash' export interface SelectOption { label: string @@ -25,19 +25,20 @@ export interface UseClassDefinitionOptionsReturn { type TreeNode = ClassDefinitionTreeNodeItem | ClassDefinitionTreeNodeFolder -const hasChildren = (node: TreeNode): node is ClassDefinitionTreeNodeFolder => { - return 'children' in node && isArray(node.children) && !isEmpty(node.children) +const isFolderNode = (node: TreeNode): node is ClassDefinitionTreeNodeFolder => { + return 'children' in node && isArray(node.children) } const flattenTreeNodes = (nodes: TreeNode[]): SelectOption[] => { return flatMap(nodes, (node) => { - const currentNode: SelectOption = { label: node.name, value: node.name } - - if (hasChildren(node)) { - return [currentNode, ...flattenTreeNodes(node.children)] + // Folder nodes are grouping containers, not selectable class definitions. + // Including them would produce options that duplicate class names (a group + // is often named after an existing class) or reference non-existent classes. + if (isFolderNode(node)) { + return flattenTreeNodes(node.children) } - return [currentNode] + return [{ label: node.name, value: node.name }] }) }