From 1a8de99d1d6092811ef699b8b9bea0d9a6b3c955 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Thu, 23 Apr 2026 16:00:09 +0200 Subject: [PATCH] Add test suite for `useDefinition` hook in `@backstage/ui` (#34042) * Add test infrastructure to @backstage/ui Signed-off-by: Johan Persson * Add resolveResponsiveValue tests Signed-off-by: Johan Persson * Add useDefinition prop resolution and classes tests Fixed a bug in useDefinition where passing null for classNameTarget or utilityTarget was incorrectly defaulted to 'root' due to the nullish coalescing operator treating null as falsy. Signed-off-by: Johan Persson * Add useDefinition data attributes tests Signed-off-by: Johan Persson * Add resolveDefinitionProps tests Signed-off-by: Johan Persson * Add processUtilityProps tests Signed-off-by: Johan Persson * Add useDefinition bg system tests Signed-off-by: Johan Persson * Add useDefinition utility style and analytics tests Signed-off-by: Johan Persson * Add useDefinition href resolution tests Signed-off-by: Johan Persson * Add useDefinition options tests Signed-off-by: Johan Persson * Wrap all useDefinition tests with BUIProvider Signed-off-by: Johan Persson * Fix type errors in useDefinition tests Use `as const satisfies ComponentConfig` instead of `as ComponentConfig` to preserve literal types needed by the conditional type machinery (DataAttributes, ResolveBgProps, analytics intersection). Signed-off-by: Johan Persson * Address review feedback in useDefinition tests - Rename shadowed Wrapper to RouterWrapper in createRouterWrapper - Fix inaccurate comment about splat vs non-splat routes - Clarify misleading test name for provider data-bg behavior Signed-off-by: Johan Persson --------- Signed-off-by: Johan Persson --- packages/ui/package.json | 2 + .../src/hooks/useDefinition/helpers.test.ts | 398 +++++++++ .../useDefinition/useDefinition.test.tsx | 753 ++++++++++++++++++ .../src/hooks/useDefinition/useDefinition.tsx | 6 +- packages/ui/src/setupTests.ts | 16 + yarn.lock | 2 + 6 files changed, 1175 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/hooks/useDefinition/helpers.test.ts create mode 100644 packages/ui/src/hooks/useDefinition/useDefinition.test.tsx create mode 100644 packages/ui/src/setupTests.ts diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b705dca41..18d2336c25 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -59,6 +59,8 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@storybook/react-vite": "^10.3.3", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@types/use-sync-external-store": "^1.0.0", diff --git a/packages/ui/src/hooks/useDefinition/helpers.test.ts b/packages/ui/src/hooks/useDefinition/helpers.test.ts new file mode 100644 index 0000000000..92735d83bc --- /dev/null +++ b/packages/ui/src/hooks/useDefinition/helpers.test.ts @@ -0,0 +1,398 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + resolveResponsiveValue, + resolveDefinitionProps, + processUtilityProps, +} from './helpers'; +import type { ComponentConfig } from './types'; + +describe('resolveResponsiveValue', () => { + it('returns a plain string unchanged', () => { + expect(resolveResponsiveValue('hello', 'md')).toBe('hello'); + }); + + it('returns a plain number unchanged', () => { + expect(resolveResponsiveValue(42, 'md')).toBe(42); + }); + + it('returns undefined unchanged', () => { + expect(resolveResponsiveValue(undefined, 'md')).toBeUndefined(); + }); + + it('returns null unchanged', () => { + expect(resolveResponsiveValue(null, 'md')).toBeNull(); + }); + + it('returns a non-breakpoint object unchanged', () => { + const obj = { foo: 'bar' }; + expect(resolveResponsiveValue(obj, 'md')).toBe(obj); + }); + + it('returns an object with only an initial key unchanged (not detected as responsive)', () => { + const obj = { initial: 'base' }; + expect(resolveResponsiveValue(obj, 'md')).toBe(obj); + }); + + it('resolves exact breakpoint match', () => { + expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'md')).toBe( + 'medium', + ); + }); + + it('falls back to the nearest smaller breakpoint', () => { + expect(resolveResponsiveValue({ xs: 'small', md: 'medium' }, 'sm')).toBe( + 'small', + ); + }); + + it('falls back across multiple missing breakpoints', () => { + expect(resolveResponsiveValue({ xs: 'small', xl: 'xlarge' }, 'lg')).toBe( + 'small', + ); + }); + + it('falls back to initial when no named breakpoint matches at or below current', () => { + expect( + resolveResponsiveValue({ initial: 'base', md: 'medium' }, 'sm'), + ).toBe('base'); + }); + + it('falls forward to the smallest available breakpoint when nothing is at or below current', () => { + expect( + resolveResponsiveValue({ md: 'medium', xl: 'xlarge' }, 'initial'), + ).toBe('medium'); + }); + + it('resolves initial breakpoint from a responsive object that includes initial', () => { + expect( + resolveResponsiveValue({ initial: 'base', xs: 'small' }, 'initial'), + ).toBe('base'); + }); + + it('skips undefined values during fallback', () => { + expect( + resolveResponsiveValue( + { xs: undefined, sm: 'small', md: undefined }, + 'md', + ), + ).toBe('small'); + }); +}); + +describe('resolveDefinitionProps', () => { + it('separates own props from rest props based on propDefs keys', () => { + const definition = { + propDefs: { variant: {}, size: {} }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved, restProps } = resolveDefinitionProps( + definition, + { variant: 'primary', size: 'large', 'aria-label': 'test' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ variant: 'primary', size: 'large' }); + expect(restProps).toEqual({ 'aria-label': 'test' }); + }); + + it('excludes utility props from rest props', () => { + const definition = { + propDefs: { variant: {} }, + utilityProps: ['m', 'p'] as const, + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { restProps } = resolveDefinitionProps( + definition, + { variant: 'primary', m: '2', p: '4', 'aria-label': 'test' }, + 'initial', + ); + + expect(restProps).toEqual({ 'aria-label': 'test' }); + }); + + it('does not exclude utility props that are also in propDefs', () => { + const definition = { + propDefs: { gap: {} }, + utilityProps: ['gap'] as const, + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { gap: '4' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ gap: '4' }); + }); + + it('applies default values from propDefs when prop is not provided', () => { + const definition = { + propDefs: { size: { default: 'medium' } }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + {}, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ size: 'medium' }); + }); + + it('does not apply default when prop is explicitly provided', () => { + const definition = { + propDefs: { size: { default: 'medium' } }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { size: 'large' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ size: 'large' }); + }); + + it('preserves falsy prop values (false, 0, empty string) over defaults', () => { + const definition = { + propDefs: { + disabled: { default: true }, + count: { default: 10 }, + label: { default: 'default' }, + }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { disabled: false, count: 0, label: '' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ + disabled: false, + count: 0, + label: '', + }); + }); + + it('resolves responsive own prop values at the given breakpoint', () => { + const definition = { + propDefs: { variant: {} }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { variant: { xs: 'small', md: 'large' } }, + 'md', + ); + + expect(ownPropsResolved).toEqual({ variant: 'large' }); + }); + + it('omits own props that are undefined and have no default', () => { + const definition = { + propDefs: { variant: {}, size: {} }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { variant: 'primary' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({ variant: 'primary' }); + expect('size' in ownPropsResolved).toBe(false); + }); + + it('passes through rest props without responsive resolution', () => { + const definition = { + propDefs: { variant: {} }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const responsiveObj = { xs: 'small', md: 'large' }; + const { restProps } = resolveDefinitionProps( + definition, + { variant: 'primary', 'data-value': responsiveObj }, + 'md', + ); + + expect(restProps['data-value']).toBe(responsiveObj); + }); + + it('returns empty ownPropsResolved when no props match propDefs', () => { + const definition = { + propDefs: { variant: {} }, + utilityProps: [], + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { ownPropsResolved } = resolveDefinitionProps( + definition, + { 'aria-label': 'test' }, + 'initial', + ); + + expect(ownPropsResolved).toEqual({}); + }); + + it('returns empty restProps when all props are own or utility props', () => { + const definition = { + propDefs: { variant: {} }, + utilityProps: ['m'] as const, + styles: {}, + classNames: {}, + } as ComponentConfig; + + const { restProps } = resolveDefinitionProps( + definition, + { variant: 'primary', m: '2' }, + 'initial', + ); + + expect(restProps).toEqual({}); + }); +}); + +describe('processUtilityProps', () => { + it('returns empty classes and style when no utility props are provided', () => { + const { utilityClasses, utilityStyle } = processUtilityProps({}, [ + 'm', + 'p', + ]); + expect(utilityClasses).toBe(''); + expect(utilityStyle).toEqual({}); + }); + + it('returns empty classes and style when utility values are undefined', () => { + const { utilityClasses, utilityStyle } = processUtilityProps( + { m: undefined }, + ['m'], + ); + expect(utilityClasses).toBe(''); + expect(utilityStyle).toEqual({}); + }); + + it('returns empty classes and style when utility values are null', () => { + const { utilityClasses, utilityStyle } = processUtilityProps({ m: null }, [ + 'm', + ]); + expect(utilityClasses).toBe(''); + expect(utilityStyle).toEqual({}); + }); + + it('generates a utility class for a predefined spacing value', () => { + const { utilityClasses } = processUtilityProps({ m: '2' }, ['m']); + expect(utilityClasses).toBe('bui-m-2'); + }); + + it('generates a CSS custom property for a custom value', () => { + const { utilityStyle } = processUtilityProps({ width: '100px' }, ['width']); + expect(utilityStyle).toEqual({ '--width': '100px' }); + }); + + it('generates both the class name and CSS var for custom values', () => { + const { utilityClasses, utilityStyle } = processUtilityProps( + { width: '100px' }, + ['width'], + ); + expect(utilityClasses).toBe('bui-w'); + expect(utilityStyle).toEqual({ '--width': '100px' }); + }); + + it('handles responsive utility values with breakpoint prefixes', () => { + const { utilityClasses } = processUtilityProps( + { m: { initial: '2', md: '4' } }, + ['m'], + ); + expect(utilityClasses).toContain('bui-m-2'); + expect(utilityClasses).toContain('md:bui-m-4'); + }); + + it('uses no prefix for the initial breakpoint in responsive values', () => { + const { utilityClasses } = processUtilityProps({ m: { initial: '2' } }, [ + 'm', + ]); + expect(utilityClasses).toBe('bui-m-2'); + }); + + it('applies the transform function (grow: true becomes 1)', () => { + const { utilityStyle } = processUtilityProps({ grow: true }, ['grow']); + expect(utilityStyle).toEqual({ '--grow': 1 }); + }); + + it('applies the transform function (basis: 42 becomes "42px")', () => { + const { utilityStyle } = processUtilityProps({ basis: 42 }, ['basis']); + expect(utilityStyle).toEqual({ '--basis': '42px' }); + }); + + it('ignores values not in the valid list for fixed-value props', () => { + const { utilityClasses, utilityStyle } = processUtilityProps( + { position: 'invalid' }, + ['position'], + ); + expect(utilityClasses).toBe(''); + expect(utilityStyle).toEqual({}); + }); + + it('combines classes and styles from multiple utility props', () => { + const { utilityClasses, utilityStyle } = processUtilityProps( + { m: '2', p: '4', width: '100px' }, + ['m', 'p', 'width'], + ); + expect(utilityClasses).toContain('bui-m-2'); + expect(utilityClasses).toContain('bui-p-4'); + expect(utilityClasses).toContain('bui-w'); + expect(utilityStyle).toEqual({ '--width': '100px' }); + }); + + it('handles a mix of predefined and custom values across different props', () => { + const { utilityClasses, utilityStyle } = processUtilityProps( + { m: '2', width: '100px', position: 'relative' }, + ['m', 'width', 'position'], + ); + expect(utilityClasses).toContain('bui-m-2'); + expect(utilityClasses).toContain('bui-w'); + expect(utilityClasses).toContain('bui-position-relative'); + expect(utilityStyle).toEqual({ '--width': '100px' }); + }); +}); diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx new file mode 100644 index 0000000000..b599b496ed --- /dev/null +++ b/packages/ui/src/hooks/useDefinition/useDefinition.test.tsx @@ -0,0 +1,753 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { type PropsWithChildren, type ReactNode } from 'react'; +import { renderHook, render } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { useDefinition } from './useDefinition'; +import type { ComponentConfig } from './types'; +import { BgProvider, useBgConsumer } from '../useBg'; +import { noopTracker } from '../../analytics/useAnalytics'; +import { BUIProvider } from '../../provider'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function BgReader() { + const { bg } = useBgConsumer(); + return {bg ?? 'none'}; +} + +function Wrapper({ children }: PropsWithChildren) { + return {children}; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const basicDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + variant: { dataAttribute: true } as const, + size: { dataAttribute: true, default: 'medium' } as const, + className: {}, + }, +} as const satisfies ComponentConfig; + +const multiSlotDef = { + styles: { root: 'css-root', content: 'css-content' }, + classNames: { root: 'root', content: 'content' }, + propDefs: { + variant: { dataAttribute: true } as const, + className: {}, + }, + utilityProps: ['m'] as const, +} as const satisfies ComponentConfig; + +const utilityDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + variant: {}, + className: {}, + }, + utilityProps: ['m', 'p', 'width'] as const, +} as const satisfies ComponentConfig; + +const providerDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + bg: { dataAttribute: true } as const, + children: {}, + className: {}, + }, + bg: 'provider' as const, +} as const satisfies ComponentConfig; + +const consumerDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + variant: {}, + className: {}, + }, + bg: 'consumer' as const, +} as const satisfies ComponentConfig; + +const analyticsDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + noTrack: {}, + className: {}, + }, + analytics: true, +} as const satisfies ComponentConfig; + +const hrefDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + href: {}, + className: {}, + }, +} as const satisfies ComponentConfig; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +function createRouterWrapper({ + basename, + currentRoute, +}: { + basename?: string; + currentRoute: string; +}) { + return function RouterWrapper({ children }: PropsWithChildren) { + const entry = basename ? `${basename}${currentRoute}` : currentRoute; + // Build nested Routes one level per path segment. The leaf route uses a + // non-splat path so that `..` resolution works correctly. + const segments = + currentRoute === '/' + ? [] + : currentRoute.replace(/^\//, '').split('/').filter(Boolean); + + const buildRoutes = (segs: string[], el: ReactNode): ReactNode => { + if (segs.length === 0) return ; + const [head, ...tail] = segs; + if (tail.length === 0) { + return ; + } + return {buildRoutes(tail, el)}; + }; + + return ( + + + + {segments.length === 0 ? ( + + ) : ( + buildRoutes(segments, children) + )} + + + + ); + }; +} + +describe('useDefinition', () => { + describe('prop resolution', () => { + it('returns resolved own props from propDefs', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.variant).toBe('primary'); + }); + + it('applies default values for missing own props', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect((result.current.ownProps as any).size).toBe('medium'); + }); + + it('returns rest props for props not in propDefs or utilityProps', () => { + const { result } = renderHook( + () => + useDefinition(basicDef, { + variant: 'primary', + 'aria-label': 'test', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.restProps).toEqual({ 'aria-label': 'test' }); + }); + + it('excludes utility props from both ownProps and restProps', () => { + const { result } = renderHook( + () => + useDefinition(utilityDef, { + variant: 'primary', + m: '2', + 'aria-label': 'test', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps).not.toHaveProperty('m'); + expect(result.current.restProps).not.toHaveProperty('m'); + expect(result.current.restProps).toEqual({ 'aria-label': 'test' }); + }); + }); + + describe('classes', () => { + it('builds a classes object with keys matching definition.classNames', () => { + const { result } = renderHook( + () => useDefinition(multiSlotDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes).toHaveProperty('root'); + expect(result.current.ownProps.classes).toHaveProperty('content'); + }); + + it('includes the base CSS class from definition.styles', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).toContain('css-root'); + }); + + it('appends user className to the root slot by default', () => { + const { result } = renderHook( + () => + useDefinition(basicDef, { + variant: 'primary', + className: 'custom', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).toContain('custom'); + }); + + it('appends utility classes to the root slot by default', () => { + const { result } = renderHook( + () => useDefinition(utilityDef, { variant: 'primary', m: '2' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).toContain('bui-m-2'); + }); + + it('appends user className to a custom classNameTarget slot', () => { + const { result } = renderHook( + () => + useDefinition( + multiSlotDef, + { variant: 'primary', className: 'custom' }, + { + classNameTarget: 'content', + }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.content).toContain('custom'); + expect(result.current.ownProps.classes.root).not.toContain('custom'); + }); + + it('appends utility classes to a custom utilityTarget slot', () => { + const { result } = renderHook( + () => + useDefinition( + multiSlotDef, + { variant: 'primary', m: '2' }, + { + utilityTarget: 'content', + }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.content).toContain('bui-m-2'); + expect(result.current.ownProps.classes.root).not.toContain('bui-m-2'); + }); + + it('does not append user className when classNameTarget is null', () => { + const { result } = renderHook( + () => + useDefinition( + basicDef, + { variant: 'primary', className: 'custom' }, + { + classNameTarget: null, + }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).not.toContain('custom'); + }); + + it('does not append utility classes when utilityTarget is null', () => { + const { result } = renderHook( + () => + useDefinition( + utilityDef, + { variant: 'primary', m: '2' }, + { + utilityTarget: null, + }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).not.toContain('bui-m-2'); + }); + + it('keeps non-targeted slots free of utility classes and user className', () => { + const { result } = renderHook( + () => + useDefinition(multiSlotDef, { + variant: 'primary', + m: '2', + className: 'custom', + }), + { wrapper: Wrapper }, + ); + + // Defaults target root — content should be clean + expect(result.current.ownProps.classes.content).not.toContain('bui-m-2'); + expect(result.current.ownProps.classes.content).not.toContain('custom'); + }); + }); + + describe('data attributes', () => { + it('generates data-* attributes for props with dataAttribute: true', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes['data-variant']).toBe('primary'); + }); + + it('does not generate data-* for props without dataAttribute', () => { + const { result } = renderHook( + () => + useDefinition(basicDef, { + variant: 'primary', + className: 'custom', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes).not.toHaveProperty( + 'data-classname', + ); + }); + + it('omits data-* when the prop value is undefined', () => { + const { result } = renderHook(() => useDefinition(basicDef, {}), { + wrapper: Wrapper, + }); + + expect(result.current.dataAttributes).not.toHaveProperty('data-variant'); + }); + + it('stringifies non-string prop values in data attributes', () => { + const numericDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + count: { dataAttribute: true } as const, + className: {}, + }, + } as const satisfies ComponentConfig; + + const { result } = renderHook( + () => useDefinition(numericDef, { count: 42 }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes['data-count']).toBe('42'); + }); + + it('does not generate data-bg from propDefs when bg=provider (uses provider path instead)', () => { + const localProviderDef = { + styles: { root: 'css-root' }, + classNames: { root: 'root' }, + propDefs: { + bg: { dataAttribute: true } as const, + children: {}, + className: {}, + }, + bg: 'provider' as const, + } as const satisfies ComponentConfig; + + const { result } = renderHook( + () => + useDefinition(localProviderDef, { + bg: 'danger', + children: null, + }), + { wrapper: Wrapper }, + ); + + // data-bg comes from the provider resolution path, not the propDef + expect(result.current.dataAttributes['data-bg']).toBe('danger'); + }); + }); + + describe('bg: provider', () => { + it('sets data-bg from the resolved provider bg value', () => { + const { result } = renderHook( + () => + useDefinition(providerDef, { + bg: 'danger', + children: null, + }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes['data-bg']).toBe('danger'); + }); + + it('does not set data-bg when bg prop is undefined', () => { + const { result } = renderHook( + () => + useDefinition(providerDef, { + children: null, + }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes).not.toHaveProperty('data-bg'); + }); + + it('adds childrenWithBgProvider to ownProps', () => { + const { result } = renderHook( + () => + useDefinition(providerDef, { + bg: 'neutral', + children: 'hello', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps).toHaveProperty('childrenWithBgProvider'); + }); + + it('wraps children in BgProvider when bg resolves to a value', () => { + const { result } = renderHook( + () => + useDefinition(providerDef, { + bg: 'neutral', + children: , + }), + { wrapper: Wrapper }, + ); + + const { getByTestId } = render( + <>{result.current.ownProps.childrenWithBgProvider}, + ); + + expect(getByTestId('bg')).toHaveTextContent('neutral-1'); + }); + + it('returns raw children as childrenWithBgProvider when bg is undefined', () => { + const childContent = hello; + const { result } = renderHook( + () => + useDefinition(providerDef, { + children: childContent, + }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.childrenWithBgProvider).toBe(childContent); + }); + + it('increments neutral bg level from parent context', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook( + () => + useDefinition(providerDef, { + bg: 'neutral', + children: , + }), + { wrapper }, + ); + + expect(result.current.dataAttributes['data-bg']).toBe('neutral-2'); + }); + }); + + describe('bg: consumer', () => { + it('sets data-on-bg from parent BgProvider context', () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + + const { result } = renderHook( + () => useDefinition(consumerDef, { variant: 'primary' }), + { wrapper }, + ); + + expect(result.current.dataAttributes['data-on-bg']).toBe('neutral-1'); + }); + + it('does not set data-on-bg when no parent BgProvider exists', () => { + const { result } = renderHook( + () => useDefinition(consumerDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg'); + }); + + it('returns children (not childrenWithBgProvider) in ownProps', () => { + const { result } = renderHook( + () => + useDefinition(consumerDef, { variant: 'primary', children: 'hello' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps).toHaveProperty('children', 'hello'); + expect(result.current.ownProps).not.toHaveProperty( + 'childrenWithBgProvider', + ); + }); + }); + + describe('no bg config', () => { + it('does not set data-bg or data-on-bg', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.dataAttributes).not.toHaveProperty('data-bg'); + expect(result.current.dataAttributes).not.toHaveProperty('data-on-bg'); + }); + + it('returns children in ownProps', () => { + const { result } = renderHook( + () => + useDefinition(basicDef, { variant: 'primary', children: 'hello' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps).toHaveProperty('children', 'hello'); + }); + }); + + describe('utility style', () => { + it('returns utilityStyle with CSS custom properties from utility props', () => { + const { result } = renderHook( + () => + useDefinition(utilityDef, { + variant: 'primary', + width: '100px', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.utilityStyle).toEqual({ '--width': '100px' }); + }); + + it('returns empty utilityStyle when no utility props are provided', () => { + const { result } = renderHook( + () => useDefinition(utilityDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current.utilityStyle).toEqual({}); + }); + }); + + describe('analytics', () => { + it('returns analytics tracker when definition.analytics is true', () => { + const { result } = renderHook(() => useDefinition(analyticsDef, {}), { + wrapper: Wrapper, + }); + + expect(result.current).toHaveProperty('analytics'); + expect(result.current.analytics).toHaveProperty('captureEvent'); + }); + + it('does not include analytics key when definition.analytics is not set', () => { + const { result } = renderHook( + () => useDefinition(basicDef, { variant: 'primary' }), + { wrapper: Wrapper }, + ); + + expect(result.current).not.toHaveProperty('analytics'); + }); + + it('returns noopTracker when noTrack is true', () => { + const { result } = renderHook( + () => useDefinition(analyticsDef, { noTrack: true }), + { wrapper: Wrapper }, + ); + + expect(result.current.analytics).toBe(noopTracker); + }); + }); + + describe('href resolution', () => { + describe('inside router context', () => { + describe.each([ + ['no basename', undefined], + ['with basename /app', '/app'], + ] as const)('%s', (_label, basename) => { + it.each` + description | href | currentRoute | expected + ${'absolute path'} | ${'/foo'} | ${'/catalog'} | ${'/foo'} + ${'root /'} | ${'/'} | ${'/catalog'} | ${'/'} + ${'relative path "foo"'} | ${'foo'} | ${'/catalog'} | ${'/catalog/foo'} + ${'relative path "./foo"'} | ${'./foo'} | ${'/catalog'} | ${'/catalog/foo'} + ${'relative path "../foo"'} | ${'../foo'} | ${'/catalog/items'} | ${'/catalog/foo'} + ${'empty string'} | ${''} | ${'/catalog'} | ${'/catalog'} + ${'absolute with query params'} | ${'/foo?q=1'} | ${'/catalog'} | ${'/foo?q=1'} + ${'absolute with hash'} | ${'/foo#section'} | ${'/catalog'} | ${'/foo#section'} + ${'absolute with query and hash'} | ${'/foo?q=1#section'} | ${'/catalog'} | ${'/foo?q=1#section'} + ${'relative with query params'} | ${'foo?q=1'} | ${'/catalog'} | ${'/catalog/foo?q=1'} + `( + 'resolves $description — returns $expected', + ({ + href, + currentRoute, + expected, + }: { + href: string; + currentRoute: string; + expected: string; + }) => { + const { result } = renderHook( + () => useDefinition(hrefDef, { href }), + { + wrapper: createRouterWrapper({ basename, currentRoute }), + }, + ); + + expect(result.current.ownProps.href).toBe(expected); + }, + ); + + it.each` + description | href + ${'https:// URL'} | ${'https://example.com'} + ${'http:// URL'} | ${'http://example.com'} + ${'mailto: link'} | ${'mailto:a@b.com'} + ${'tel: link'} | ${'tel:123'} + ${'protocol-relative URL'} | ${'//example.com'} + `('leaves $description unchanged', ({ href }: { href: string }) => { + const { result } = renderHook( + () => useDefinition(hrefDef, { href }), + { + wrapper: createRouterWrapper({ + basename, + currentRoute: '/catalog', + }), + }, + ); + + expect(result.current.ownProps.href).toBe(href); + }); + + it('does not modify props when href is undefined', () => { + const { result } = renderHook(() => useDefinition(hrefDef, {}), { + wrapper: createRouterWrapper({ + basename, + currentRoute: '/catalog', + }), + }); + + expect(result.current.ownProps).not.toHaveProperty('href'); + }); + }); + }); + + describe('outside router context', () => { + it('passes all href values through unchanged', () => { + const { result } = renderHook( + () => useDefinition(hrefDef, { href: '/foo' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.href).toBe('/foo'); + }); + }); + }); + + describe('options', () => { + it('utilityTarget defaults to root', () => { + const { result } = renderHook( + () => useDefinition(multiSlotDef, { variant: 'primary', m: '2' }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).toContain('bui-m-2'); + expect(result.current.ownProps.classes.content).not.toContain('bui-m-2'); + }); + + it('classNameTarget defaults to root', () => { + const { result } = renderHook( + () => + useDefinition(multiSlotDef, { + variant: 'primary', + className: 'custom', + }), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.root).toContain('custom'); + expect(result.current.ownProps.classes.content).not.toContain('custom'); + }); + + it('custom utilityTarget applies utility classes to that slot', () => { + const { result } = renderHook( + () => + useDefinition( + multiSlotDef, + { variant: 'primary', m: '2' }, + { utilityTarget: 'content' }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.content).toContain('bui-m-2'); + expect(result.current.ownProps.classes.root).not.toContain('bui-m-2'); + }); + + it('custom classNameTarget applies className to that slot', () => { + const { result } = renderHook( + () => + useDefinition( + multiSlotDef, + { variant: 'primary', className: 'custom' }, + { classNameTarget: 'content' }, + ), + { wrapper: Wrapper }, + ); + + expect(result.current.ownProps.classes.content).toContain('custom'); + expect(result.current.ownProps.classes.root).not.toContain('custom'); + }); + }); +}); diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index a6faf58d1f..a0e3c53164 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -122,8 +122,10 @@ export function useDefinition< analytics = ownPropsResolved.noTrack ? noopTracker : tracker; } - const utilityTarget = options?.utilityTarget ?? 'root'; - const classNameTarget = options?.classNameTarget ?? 'root'; + const utilityTarget = + options?.utilityTarget !== undefined ? options.utilityTarget : 'root'; + const classNameTarget = + options?.classNameTarget !== undefined ? options.classNameTarget : 'root'; const classes: Record = {}; diff --git a/packages/ui/src/setupTests.ts b/packages/ui/src/setupTests.ts new file mode 100644 index 0000000000..5f11bab1c2 --- /dev/null +++ b/packages/ui/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 1f5038975f..8688308919 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7949,6 +7949,8 @@ __metadata: "@remixicon/react": "npm:^4.6.0" "@storybook/react-vite": "npm:^10.3.3" "@tanstack/react-table": "npm:^8.21.3" + "@testing-library/jest-dom": "npm:^6.0.0" + "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" "@types/react-dom": "npm:^18.0.0" "@types/use-sync-external-store": "npm:^1.0.0"